sheets_db 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 0522857a175930f3d8da223feca5a4c6c2776416
4
+ data.tar.gz: 8675133f8dc45b598ec153bd15a2e0929ab518ec
5
+ SHA512:
6
+ metadata.gz: 829c7a9ba78e488acb5b7a53f1e73dfb8c81f0852269e20b318490dc239fe448fbf35df7cf9081f3c86cbe66f255c2ac21f7db7f8bfc8123b3bfeb3d86440fa0
7
+ data.tar.gz: d92a4221ac985d3075af370a7137d4ed305437d4b854b234068864a6268e61f2b37b8a81517beaf887cf5d76715d97feb1a28c0eef4430c0bf781f50074d1329
data/.gitignore ADDED
@@ -0,0 +1,9 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 2.3.
data/.travis.yml ADDED
@@ -0,0 +1,5 @@
1
+ sudo: false
2
+ language: ruby
3
+ rvm:
4
+ - 2.3.1
5
+ before_install: gem install bundler -v 1.12.5
@@ -0,0 +1,49 @@
1
+ # Contributor Code of Conduct
2
+
3
+ As contributors and maintainers of this project, and in the interest of
4
+ fostering an open and welcoming community, we pledge to respect all people who
5
+ contribute through reporting issues, posting feature requests, updating
6
+ documentation, submitting pull requests or patches, and other activities.
7
+
8
+ We are committed to making participation in this project a harassment-free
9
+ experience for everyone, regardless of level of experience, gender, gender
10
+ identity and expression, sexual orientation, disability, personal appearance,
11
+ body size, race, ethnicity, age, religion, or nationality.
12
+
13
+ Examples of unacceptable behavior by participants include:
14
+
15
+ * The use of sexualized language or imagery
16
+ * Personal attacks
17
+ * Trolling or insulting/derogatory comments
18
+ * Public or private harassment
19
+ * Publishing other's private information, such as physical or electronic
20
+ addresses, without explicit permission
21
+ * Other unethical or unprofessional conduct
22
+
23
+ Project maintainers have the right and responsibility to remove, edit, or
24
+ reject comments, commits, code, wiki edits, issues, and other contributions
25
+ that are not aligned to this Code of Conduct, or to ban temporarily or
26
+ permanently any contributor for other behaviors that they deem inappropriate,
27
+ threatening, offensive, or harmful.
28
+
29
+ By adopting this Code of Conduct, project maintainers commit themselves to
30
+ fairly and consistently applying these principles to every aspect of managing
31
+ this project. Project maintainers who do not follow or enforce the Code of
32
+ Conduct may be permanently removed from the project team.
33
+
34
+ This code of conduct applies both within project spaces and in public spaces
35
+ when an individual is representing the project or its community.
36
+
37
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
38
+ reported by contacting a project maintainer at ravi@ablschools.com. All
39
+ complaints will be reviewed and investigated and will result in a response that
40
+ is deemed necessary and appropriate to the circumstances. Maintainers are
41
+ obligated to maintain confidentiality with regard to the reporter of an
42
+ incident.
43
+
44
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage],
45
+ version 1.3.0, available at
46
+ [http://contributor-covenant.org/version/1/3/0/][version]
47
+
48
+ [homepage]: http://contributor-covenant.org
49
+ [version]: http://contributor-covenant.org/version/1/3/0/
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in sheets_db.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2016 Ravi Gadad
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,41 @@
1
+ # SheetsDB
2
+
3
+ SheetsDB is essentially a Ruby ORM for tabular and relational data stored in Google Sheets. While storing relational data in a spreadsheet is kind of like using a pint glass to hammer a nail, there are situations in which this can be useful, especially for prototyping or collaborating with people who aren't as comfortable seeding data into an RDBMS.
4
+
5
+ There are some conventions you must follow in your Google drive folders and the Sheets themselves, but most setup of attributes, primitive type mapping, etc. is done within your object classes themselves.
6
+
7
+ ## Installation
8
+
9
+ Add this line to your application's Gemfile:
10
+
11
+ ```ruby
12
+ gem 'sheets_db'
13
+ ```
14
+
15
+ And then execute:
16
+
17
+ $ bundle
18
+
19
+ Or install it yourself as:
20
+
21
+ $ gem install sheets_db
22
+
23
+ ## Usage
24
+
25
+ View usage documentation [here](documentation/usage.md).
26
+
27
+ ## Development
28
+
29
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
30
+
31
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
32
+
33
+ ## Contributing
34
+
35
+ Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/sheets_db. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
36
+
37
+
38
+ ## License
39
+
40
+ The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
41
+
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "sheets_db"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,3 @@
1
+ class Organization < SheetsDB::Collection
2
+ has_many :teams, class_name: "Team"
3
+ end
@@ -0,0 +1,7 @@
1
+ class Pet < SheetsDB::Worksheet::Row
2
+ attribute :id
3
+ attribute :first_name, transform: ->(first_name) { first_name.upcase }
4
+ attribute :favorite_colors, multiple: true
5
+
6
+ belongs_to_one :parent, from_collection: :users, foreign_key: :pet_ids
7
+ end
@@ -0,0 +1,6 @@
1
+ class Project < SheetsDB::Spreadsheet
2
+ has_many :tasks, worksheet_name: "Tasks", class_name: "Task"
3
+ has_many :users, worksheet_name: "Users", class_name: "User"
4
+ has_many :pets, worksheet_name: "Pets", class_name: "Pet"
5
+ belongs_to_many :teams, class_name: "Team"
6
+ end
@@ -0,0 +1,10 @@
1
+ class Task < SheetsDB::Worksheet::Row
2
+ attribute :id, type: Integer
3
+ attribute :name
4
+ attribute :creator_id, type: Integer
5
+ attribute :assignee_id, type: Integer
6
+ attribute :finished_at, type: DateTime
7
+
8
+ has_one :creator, from_collection: :users, key: :creator_id
9
+ has_one :assignee, from_collection: :users, key: :assignee_id
10
+ end
@@ -0,0 +1,8 @@
1
+ class Team < SheetsDB::Collection
2
+ has_many :projects, class_name: "Project", resource_type: :spreadsheets
3
+ belongs_to_many :organizations, class_name: "Organization"
4
+
5
+ def organization
6
+ organizations.first
7
+ end
8
+ end
@@ -0,0 +1,10 @@
1
+ class User < SheetsDB::Worksheet::Row
2
+ attribute :id, type: Integer
3
+ attribute :first_name
4
+ attribute :last_name
5
+ attribute :pet_ids, multiple: true
6
+
7
+ has_many :pets, from_collection: :pets, key: :pet_ids
8
+
9
+ belongs_to_many :created_tasks, from_collection: :tasks, foreign_key: :creator_id
10
+ end
@@ -0,0 +1,184 @@
1
+ # SheetsDB
2
+
3
+ ## Usage
4
+
5
+ **NOTE**: Currently, SheetsDB only supports reading and updating entities within Google Drive and Sheets; adding new entities or deleting existing ones is not available yet. Also, updating is restricted to worksheet rows, so folders and spreadsheets are not yet renamable using SheetsDB.
6
+
7
+ ### The Data Model
8
+
9
+ For the sake of documentation, we'll set up an example data model that looks like the following:
10
+
11
+ ![](images/example_erd.png)
12
+
13
+ SheetsDB works with several layers of the hierarchy in Google Sheets and Drive (within a worksheet, across worksheets in a Sheet, and across folders in Drive). We'll briefly go over each of these, and then later we'll dive more deeply.
14
+
15
+ #### Worksheets
16
+
17
+ Worksheets are the best analog we have to database tables - they contain rows, and we can use one of the rows as a header to establish columns. In SheetsDB, a worksheet is the only way we can represent arbitrary tabular data (outside of relationships that map directly to the folder and file structure in Google Drive). Therefore, for the example above, we'll need to use worksheets to describe the `Task`, `User`, and `Pet` models.
18
+
19
+ #### Spreadsheets
20
+
21
+ All worksheets live in a spreadsheet, and there is no way to access a worksheet or its rows outside of the spreadsheet context. Therefore, there will always be a single parent relationship from the worksheet-based models to a spreadsheet-based model, in this case `Project`. Note that we can't persist any arbitrary metadata at the project level in this example - if we needed to, `Project` would need to become a worksheet-based model. Within the spreadsheet, worksheets can contain rows having relationships to rows in other worksheets.
22
+
23
+ #### Folders
24
+
25
+ Folders can contain spreadsheets, other folders, or both (obviously Drive folders can also contain other types of files, but SheetsDB ignores these). They inherently have a zero-to-many relationship with subfolders or any number of spreadsheets, and also (unless they are root folders) can have a one-to-many relationship to parent folders. Therefore, `Organization` and `Team` will both be folder-based models.
26
+
27
+ Note that subfolders within a folder-based model must all be the same type - there is no support for different kinds of subfolder relationships from a single folder type. For example, in the data model we're working with, we couldn't have some subfolders of an `Organization` folder be `Team` folders while others were "`Building`" folders. The same goes for spreadsheets within a folder-based model - only one spreadsheet type relationship is possible.
28
+
29
+ You *can*, however, have a single type of subfolder relationship and also a single type of spreadsheet relationship. In the example above, that would mean a `Team` folder could have many spreadsheets, which would all have to be set up as `Project` spreadsheets; but also many subfolders, which would all have to be set up as the same kind of folder-based model.
30
+
31
+ ### Setting Up the Structure in Google Drive
32
+
33
+ To create the structures in Google Drive to represent our example data model, we'll start at the top and work our way down the hierarchy.
34
+
35
+ #### Folders
36
+
37
+ First, go into your Google Drive account and create a new folder that will represent the top of our hierarchy. It doesn't matter where you create this folder, since we'll reference it directly by its id within Google Drive; it can be at the root, or deep within nested folders. Let's call this folder "Organization A" (though the folder name is meaningless); we'll map this to an `Organization` instance in SheetsDB.
38
+
39
+ Since our data model specifies that an `Organization` has zero-to-many `Team`s, and `Team` is a folder-based model, all subfolders in our new folder will represent teams. Navigate into the "Organization A" folder and create two new folders: "Team Alpha" and "Team Bravo". We're creating two just to demonstrate the subfolder relationship, but we'll work only in "Team Alpha" for now.
40
+
41
+ #### Spreadsheets
42
+
43
+ Navigate into the "Team Alpha" folder. This folder represents a `Team` in our data model, and `Team`s have many `Project`s. A `Project` is a spreadsheet-based model, so let's create a new Google Sheets spreadsheet in this folder. Name your new empty spreadsheet "Test Project 1". Later, when you want to create a new `Project`, you'll go back into one of your `Team` folders and create a new spreadsheet there; for now, we'll just use a single `Project` spreadsheet.
44
+
45
+ #### Worksheets
46
+
47
+ Now we get to the fun part - representing tabular data and relationships between worksheets. In our data model, a `Project` has three child relationships - `Task`s, `User`s, and `Pet`s. Therefore, let's create three worksheets. What you name these worksheets is flexible and doesn't have to be the same as the relationship name, but obviously it'll be less confusing to map them semantically. So let's call the three worksheets "Tasks", "Users", and "Pets".
48
+
49
+ Within the "Tasks" worksheet, set up a header row with each cell being one of the `Task` column names from our data model (`id`, `name`, `creator_id`, `assignee_id`, and `finished_at`). Repeat this for the "Users" worksheet with `User` columns (`id`, `first_name`, `last_name`, and `pet_ids`), and the "Pets" worksheet with `Pet` columns (`id`, `first_name`, and `favorite_colors`).
50
+
51
+ Now let's fill in some dummy data. Make sure any entries in association columns (like `creator_id` and `pet_ids`) reference the `id` column in their respective child worksheets. For multiple value columns (`pet_ids` and `favorite_colors`), use a comma-separated list of values (__currently, values that contain commas themselves aren't supported - we'll add escaping in a later version__).
52
+
53
+ When you're done, your spreadsheet should look similar to the following:
54
+
55
+ ![](images/example_spreadsheet_data.png)
56
+
57
+ **A few things to note:**
58
+
59
+ 1. Empty cells will result in null values. Blank string values are currently not supported in any way.
60
+ 2. The `finished_at` column actually represents a date & time in Google Sheets and not an arbitrary string field; you can confirm this by highlighting one of those cells and seeing the value in the formula bar. If you want to use a column as a Ruby `DateTime` in SheetsDB, make sure the actual data is formatted as `%m/%d/%Y %H:%M:%S` (single digit months and days are acceptable).
61
+ 3. In comma-separated lists of values, spaces after commas are ignored, so "1, 2, 3" works just as well as "1,2,3".
62
+
63
+ ### Accessing the Data
64
+
65
+ Now that we're done entering all our data, let's set up our models in Ruby so we can start reading and updating this data. All the example model classes below are also in the [examples](example_models) directory.
66
+
67
+ #### Creating a Session
68
+
69
+ To access your files in Google Drive, you need to start by creating a SheetsDB::Session. This is done by wrapping a GoogleDrive::Session. There are several ways to do this, all described in the `google_drive` gem's documentation [here](https://github.com/gimite/google-drive-ruby/blob/master/doc/authorization.md). Note that the GoogleDrive gem is already available to you if you're using SheetsDB, as it is a loaded dependency.
70
+
71
+ Once you have a GoogleDrive::Session, create a SheetsDB::Session using it:
72
+
73
+ ```ruby
74
+ google_drive_session = GoogleDrive::Session.from_config("config.json")
75
+ sheets_db_session = SheetsDB::Session.new(google_drive_session)
76
+ ```
77
+
78
+ You'll then use this session when fetching Sheets or folders from Google Drive, by id:
79
+
80
+ ```ruby
81
+ # in this example, Organization is a class inheriting from SheetsDB::Collection
82
+ Organization.find_by_id("b3FeFCg3p-IyjdL27crOR", session: sheets_db_session)
83
+ ```
84
+
85
+ However, in cases where you're using a single session for all your requests to Drive (for example, if you're using a service account rather than OAuth), you can set an instance of SheetsDB::Session as your default, and that default will be used for your requests until it's changed or the class is reloaded.
86
+
87
+ ```ruby
88
+ SheetsDB::Session.default = sheets_db_session
89
+ Organization.find_by_id("b3FeFCg3p-IyjdL27crOR")
90
+ ```
91
+
92
+ Make sure the user or service account you've authenticated as, in your session, has access to the folder hierarchy we created earlier.
93
+
94
+ Great, now we have a session and we can fetch resources from Google Drive!
95
+
96
+ #### Folder Models
97
+
98
+ Model classes representing folders should inherit from `SheetsDB::Collection`, and can have relationships to subfolders using the `has_many` class method. Here is our `Organization` class:
99
+
100
+ ```ruby
101
+ class Organization < SheetsDB::Collection
102
+ has_many :teams, class_name: "Team"
103
+ end
104
+ ```
105
+
106
+ Our `Team` class is similar, but specifies that `Project` children are spreadsheets (instead of subfolders), and also uses the `belongs_to_many` method to define the parent relationship to `Organization`. Note that there is no way to restrict a folder or file's parent structure to allow only one parent; therefore, there is no `belongs_to_one` method. If you know your structure will only ever have a single parent for a given resource, you could set up a method to access the first element of this collection, as we've done in the `Team` class:
107
+
108
+ ```ruby
109
+ class Team < SheetsDB::Collection
110
+ has_many :projects, class_name: "Project", resource_type: :spreadsheets
111
+ belongs_to_many :organizations, class_name: "Organization"
112
+
113
+ def organization
114
+ organizations.first
115
+ end
116
+ end
117
+ ```
118
+
119
+ #### Spreadsheet Models
120
+
121
+ The `Project` model uses a different base class (`SheetsDB::Spreadsheet`). From this type of model class, the `has_many` method references worksheets instead of subfolders or spreadsheets, so it has a different signature:
122
+
123
+ ```ruby
124
+ class Project < SheetsDB::Spreadsheet
125
+ has_many :tasks, worksheet_name: "Tasks", class_name: "Task"
126
+ has_many :users, worksheet_name: "Users", class_name: "User"
127
+ has_many :pets, worksheet_name: "Pets", class_name: "Pet"
128
+ belongs_to_many :teams, class_name: "Team"
129
+ end
130
+ ```
131
+
132
+ #### Worksheet Row Models
133
+
134
+ Finally, we get to the worksheets. We're not trying to model the worksheets themselves - those are analogous to the schema of a table. What we're modeling is rows within the worksheets, so each of these models will inherit from `SheetsDB::Worksheet::Row`. We'll need to specify what the attributes are, to set up accessor methods and to specify their types; we'll also be setting up relationships to other worksheet models. Here are the three models:
135
+
136
+ ```ruby
137
+ class Task < SheetsDB::Worksheet::Row
138
+ attribute :id, type: Integer
139
+ attribute :name
140
+ attribute :creator_id, type: Integer
141
+ attribute :assignee_id, type: Integer
142
+ attribute :finished_at, type: DateTime
143
+
144
+ has_one :creator, from_collection: :users, key: :creator_id
145
+ has_one :assignee, from_collection: :users, key: :assignee_id
146
+ end
147
+
148
+ class User < SheetsDB::Worksheet::Row
149
+ attribute :id, type: Integer
150
+ attribute :first_name
151
+ attribute :last_name
152
+ attribute :pet_ids, multiple: true
153
+
154
+ has_many :pets, from_collection: :pets, key: :pet_ids
155
+
156
+ belongs_to_many :created_tasks, from_collection: :tasks, foreign_key: :creator_id
157
+ end
158
+
159
+ class Pet < SheetsDB::Worksheet::Row
160
+ attribute :id
161
+ attribute :first_name, transform: ->(first_name) { first_name.upcase }
162
+ attribute :favorite_colors, multiple: true
163
+
164
+ belongs_to_one :parent, from_collection: :users, foreign_key: :pet_ids
165
+ end
166
+ ```
167
+
168
+ ##### The Attribute Method
169
+
170
+ The only required argument to the `attribute` class method is the name of the column itself. All other options are optional:
171
+
172
+ `type`: The `attribute` method currently supports three types: `Integer`, `DateTime`, and the default `String`. Any other value given to the `type` option will be ignored and the default will be used.
173
+
174
+ `multiple`: This option defaults to false. If true, it tells SheetsDB to parse the value as a comma-separated list of values, and return an array by splitting on the comma (if no comma is found, it will return a single element array).
175
+
176
+ `transform`: If you supply a Proc (or lambda) to the `transform` option, reading this column from the spreadsheet will cause it to first be sent through this transformation Proc. For `multiple` columns, each element will be transformed independently.
177
+
178
+ ##### `has_many` and `has_one` associations
179
+
180
+ For associations in which the key exists in the specifying row, use `has_many` or `has_one`. The `from_collection` parameter should reference the `has_many` entry on the Spreadsheet model that maps to the target worksheet, and the `key` parameter is the name of the local column that maps to the foreign worksheet's `id` column.
181
+
182
+ ##### `belongs_to_many` and `belongs_to_one` associations
183
+
184
+ For associations in which the key exists in the foreign row, use `belongs_to_many` or `belongs_to_one`. The `from_collection` parameter should reference the `has_many` entry on the Spreadsheet model that maps to the target worksheet, and the `foreign_key` parameter is the name of the foreign worksheet's column that maps to the local worksheet's `id` column.
@@ -0,0 +1,18 @@
1
+ module SheetsDB
2
+ class Collection < Resource
3
+ set_resource_type GoogleDrive::Collection
4
+
5
+ def self.has_many(resource, class_name:, resource_type: :subcollections)
6
+ unless [:subcollections, :spreadsheets].include?(resource_type)
7
+ raise ArgumentError, "resource_type must be :subcollections or :spreadsheets"
8
+ end
9
+ register_association(resource, class_name: class_name, resource_type: resource_type)
10
+ define_method(resource) do
11
+ result = instance_variable_get(:"@#{resource}")
12
+ result || instance_variable_set(:"@#{resource}",
13
+ google_drive_resource.send(resource_type).map { |raw| Support.constantize(class_name).new(raw) }
14
+ )
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,68 @@
1
+ module SheetsDB
2
+ class Resource
3
+ class ResourceTypeMismatchError < StandardError; end
4
+ class CollectionTypeAlreadyRegisteredError < StandardError; end
5
+
6
+ class << self
7
+ attr_reader :resource_type
8
+
9
+ def set_resource_type(resource_type)
10
+ @resource_type = resource_type
11
+ end
12
+
13
+ def find_by_id(id, session: SheetsDB::Session.default)
14
+ google_drive_resource = session.raw_file_by_id(id)
15
+ if @resource_type && !google_drive_resource.is_a?(@resource_type)
16
+ fail(ResourceTypeMismatchError, "The file with id #{id} is not a #{@resource_type}")
17
+ end
18
+ new(google_drive_resource)
19
+ end
20
+
21
+ def belongs_to_many(resource, class_name:)
22
+ register_association(resource, class_name: class_name, resource_type: :parents)
23
+ define_method(resource) do
24
+ result = instance_variable_get(:"@#{resource}")
25
+ result || instance_variable_set(:"@#{resource}",
26
+ google_drive_resource.parents.map { |id| Support.constantize(class_name).find_by_id(id) }
27
+ )
28
+ end
29
+ end
30
+
31
+ def register_association(resource, class_name:, resource_type:)
32
+ @associations ||= {}
33
+ if @associations.values.any? { |value| value[:resource_type] == resource_type }
34
+ raise CollectionTypeAlreadyRegisteredError
35
+ end
36
+ @associations[resource] = {
37
+ resource_type: resource_type,
38
+ class_name: class_name
39
+ }
40
+ end
41
+ end
42
+
43
+ extend Forwardable
44
+ def_delegators :google_drive_resource, :id, :name
45
+ def_delegator :google_drive_resource, :created_time, :created_at
46
+ def_delegator :google_drive_resource, :modified_time, :updated_at
47
+
48
+ attr_reader :google_drive_resource
49
+
50
+ def initialize(google_drive_resource)
51
+ @google_drive_resource = google_drive_resource
52
+ end
53
+
54
+ def ==(other)
55
+ other.is_a?(self.class) &&
56
+ other.google_drive_resource == google_drive_resource
57
+ end
58
+
59
+ def base_attributes
60
+ {
61
+ id: id,
62
+ name: name,
63
+ created_at: created_at,
64
+ updated_at: updated_at
65
+ }
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,34 @@
1
+ require "google_drive"
2
+
3
+ module SheetsDB
4
+ class Session
5
+ class IllegalDefaultError < StandardError; end
6
+ class NoDefaultSetError < StandardError; end
7
+
8
+ def self.default=(default)
9
+ unless default.is_a?(self)
10
+ raise IllegalDefaultError.new("Default must be a SheetsDB::Session")
11
+ end
12
+ @default = default
13
+ end
14
+
15
+ def self.default
16
+ unless @default
17
+ raise NoDefaultSetError.new("No default Session defined yet")
18
+ end
19
+ @default
20
+ end
21
+
22
+ def self.from_service_account_key(*args)
23
+ new(GoogleDrive::Session.from_service_account_key(*args))
24
+ end
25
+
26
+ def initialize(google_drive_session)
27
+ @google_drive_session = google_drive_session
28
+ end
29
+
30
+ def raw_file_by_id(id)
31
+ @google_drive_session.file_by_id(id)
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,46 @@
1
+ module SheetsDB
2
+ class Spreadsheet < Resource
3
+ class WorksheetAssociationAlreadyRegisteredError < StandardError; end
4
+
5
+ set_resource_type GoogleDrive::Spreadsheet
6
+
7
+ def self.has_many(resource, worksheet_name:, class_name:)
8
+ register_worksheet_association(resource, worksheet_name: worksheet_name, class_name: class_name)
9
+ define_method(resource) do
10
+ @worksheets ||= {}
11
+ @worksheets[resource] ||= Worksheet.new(
12
+ spreadsheet: self,
13
+ google_drive_resource: google_drive_resource.worksheet_by_title(worksheet_name),
14
+ type: Support.constantize(class_name)
15
+ )
16
+ end
17
+ end
18
+
19
+ def self.register_worksheet_association(resource, worksheet_name:, class_name:)
20
+ @associations ||= {}
21
+ if @associations.fetch(resource, nil)
22
+ raise WorksheetAssociationAlreadyRegisteredError
23
+ end
24
+ @associations[resource] = {
25
+ worksheet_name: worksheet_name,
26
+ class_name: class_name
27
+ }
28
+ end
29
+
30
+ def find_association_by_id(association_name, id)
31
+ find_associations_by_ids(association_name, [id]).first
32
+ end
33
+
34
+ def find_associations_by_ids(association_name, ids)
35
+ send(association_name).find_by_ids(ids)
36
+ end
37
+
38
+ def find_associations_by_attribute(association_name, attribute_name, value)
39
+ send(association_name).find_by_attribute(attribute_name, value)
40
+ end
41
+
42
+ def select_from_association(association_name, &block)
43
+ send(association_name).select(&block)
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,22 @@
1
+ module SheetsDB
2
+ module Support
3
+ class << self
4
+ def camelize(string)
5
+ string = string.sub(/^[a-z\d]*/) { $&.capitalize }
6
+ string = string.gsub(/(?:_|(\/))([a-z\d]*)/) { "#{$1}#{$2.capitalize}" }.gsub('/', '::')
7
+ end
8
+
9
+ def constantize(string)
10
+ name_parts = camelize(string).split('::')
11
+ name_parts.shift if name_parts.first.empty?
12
+ constant = Object
13
+
14
+ name_parts.each do |name_part|
15
+ constant_defined = constant.const_defined?(name_part, false)
16
+ constant = constant_defined ? constant.const_get(name_part) : constant.const_missing(name_part)
17
+ end
18
+ constant
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,3 @@
1
+ module SheetsDB
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,18 @@
1
+ module SheetsDB
2
+ class Worksheet
3
+ class Column
4
+ attr_reader :name, :column_position
5
+
6
+ def initialize(name:, column_position:)
7
+ @name = name
8
+ @column_position = column_position
9
+ end
10
+
11
+ def ==(other)
12
+ other.is_a?(self.class) &&
13
+ other.name == name &&
14
+ other.column_position == column_position
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,221 @@
1
+ module SheetsDB
2
+ class Worksheet
3
+ class Row
4
+ class AttributeAlreadyRegisteredError < StandardError; end
5
+ class AssociationAlreadyRegisteredError < StandardError; end
6
+
7
+ class << self
8
+ attr_reader :association_definitions
9
+
10
+ def inherited(subclass)
11
+ super
12
+ subclass.instance_variable_set(:@attribute_definitions, @attribute_definitions)
13
+ subclass.instance_variable_set(:@association_definitions, @association_definitions)
14
+ end
15
+
16
+ def attribute(name, type: String, multiple: false, transform: nil)
17
+ register_attribute(name, type: type, multiple: multiple, transform: transform, association: false)
18
+
19
+ define_method(name) do
20
+ begin
21
+ get_modified_attribute(name)
22
+ rescue KeyError
23
+ get_persisted_attribute(name)
24
+ end
25
+ end
26
+
27
+ define_method("#{name}=") do |value|
28
+ stage_attribute_modification(name, value)
29
+ end
30
+ end
31
+
32
+ def has_association(name, from_collection:, key:, multiple: false)
33
+ register_attribute(name, from_collection: from_collection, key: key, multiple: multiple, association: true)
34
+
35
+ define_method(name) do
36
+ @loaded_associations[name] ||= begin
37
+ response = spreadsheet.find_associations_by_ids(from_collection, Array(send(key)))
38
+ multiple ? response : response.first
39
+ end
40
+ end
41
+
42
+ define_method("#{name}=") do |value|
43
+ assignment_value = multiple ? value.map(&:id) : value.id
44
+ send("#{key}=", assignment_value)
45
+ @loaded_associations[name] = value
46
+ end
47
+ end
48
+
49
+ def has_one(name, from_collection:, key:)
50
+ has_association(name, from_collection: from_collection, key: key, multiple: false)
51
+ end
52
+
53
+ def has_many(name, from_collection:, key:)
54
+ has_association(name, from_collection: from_collection, key: key, multiple: true)
55
+ end
56
+
57
+ def belongs_to_association(name, from_collection:, foreign_key:, multiple: false)
58
+ register_attribute(name, from_collection: from_collection, foreign_key: foreign_key, multiple: multiple, association: true)
59
+
60
+ define_method(name) do
61
+ @loaded_associations[name] ||= begin
62
+ response = spreadsheet.find_associations_by_attribute(from_collection, foreign_key, id)
63
+ multiple ? response : response.first
64
+ end
65
+ end
66
+
67
+ define_method("#{name}=") do |value|
68
+ existing_values = Array(send(name))
69
+ Array(value).each do |foreign_item|
70
+ next if existing_values.delete(foreign_item)
71
+ foreign_item.add_element_to_attribute(foreign_key, id)
72
+ @changed_foreign_items << foreign_item
73
+ end
74
+ existing_values.each do |existing_foreign_item|
75
+ existing_foreign_item.remove_element_from_attribute(foreign_key, id)
76
+ @changed_foreign_items << existing_foreign_item
77
+ end
78
+ @loaded_associations[name] = value
79
+ end
80
+ end
81
+
82
+ def belongs_to_one(name, from_collection:, foreign_key:)
83
+ belongs_to_association(name, from_collection: from_collection, foreign_key: foreign_key, multiple: false)
84
+ end
85
+
86
+ def belongs_to_many(name, from_collection:, foreign_key:)
87
+ belongs_to_association(name, from_collection: from_collection, foreign_key: foreign_key, multiple: true)
88
+ end
89
+
90
+ def register_attribute(name, **options)
91
+ if attribute_definitions.fetch(name, nil)
92
+ raise AttributeAlreadyRegisteredError, name
93
+ end
94
+ attribute_definitions[name] = options
95
+ end
96
+
97
+ def attribute_definitions
98
+ @attribute_definitions ||= {}
99
+ end
100
+ end
101
+
102
+ attr_reader :worksheet, :row_position, :loaded_attributes, :loaded_associations, :changed_foreign_items
103
+
104
+ def initialize(worksheet:, row_position:)
105
+ @worksheet = worksheet
106
+ @row_position = row_position
107
+ @loaded_attributes = {}
108
+ @loaded_associations = {}
109
+ @changed_foreign_items = []
110
+ end
111
+
112
+ def get_modified_attribute(name)
113
+ loaded_attributes.fetch(name, {}).
114
+ fetch(:changed)
115
+ end
116
+
117
+ def get_persisted_attribute(name)
118
+ loaded_attributes[name] ||= {}
119
+ loaded_attributes[name][:original] ||=
120
+ worksheet.attribute_at_row_position(name, row_position)
121
+ end
122
+
123
+ def stage_attribute_modification(name, value)
124
+ loaded_attributes[name] ||= {}
125
+ loaded_attributes[name][:changed] = value
126
+ end
127
+
128
+ def modify_collection_attribute(name, value, remove:)
129
+ attribute_definition = self.class.attribute_definitions.fetch(name, {})
130
+ existing_value = Array(send(name))
131
+ return if remove && !existing_value.include?(value)
132
+ return if !remove && existing_value.include?(value)
133
+ assignment_value = if attribute_definition[:multiple]
134
+ remove ? existing_value - [value] : existing_value.concat([value])
135
+ else
136
+ remove ? nil : value
137
+ end
138
+ send("#{name}=", assignment_value)
139
+ end
140
+
141
+ def add_element_to_attribute(name, value)
142
+ modify_collection_attribute(name, value, remove: false)
143
+ end
144
+
145
+ def remove_element_from_attribute(name, value)
146
+ modify_collection_attribute(name, value, remove: true)
147
+ end
148
+
149
+ def reload!
150
+ worksheet.reload!
151
+ reset_attributes_and_associations_cache
152
+ end
153
+
154
+ def save!
155
+ worksheet.update_attributes_at_row_position(staged_attributes, row_position: row_position)
156
+ save_changed_foreign_items!
157
+ reset_attributes_and_associations_cache
158
+ end
159
+
160
+ def save_changed_foreign_items!
161
+ changed_foreign_items.each do |foreign_item|
162
+ foreign_item.save!
163
+ end
164
+ end
165
+
166
+ def reset_attributes_and_associations_cache
167
+ @loaded_attributes = {}
168
+ @loaded_associations = {}
169
+ end
170
+
171
+ def staged_attributes
172
+ loaded_attributes.each_with_object({}) { |(key, value), hsh|
173
+ next unless value
174
+ hsh[key] = value[:changed] if value[:changed]
175
+ hsh
176
+ }
177
+ end
178
+
179
+ def spreadsheet
180
+ worksheet.spreadsheet
181
+ end
182
+
183
+ def attributes
184
+ get_all_attributes do |options|
185
+ !options.fetch(:association, false)
186
+ end
187
+ end
188
+
189
+ def associations
190
+ get_all_attributes do |options|
191
+ options.fetch(:association, false)
192
+ end
193
+ end
194
+
195
+ def get_all_attributes(&block)
196
+ self.class.attribute_definitions.each_with_object({}) { |(name, options), memo|
197
+ memo[name] = send(name) if block.call(options)
198
+ memo
199
+ }
200
+ end
201
+
202
+ def to_hash(depth: 0)
203
+ hashed_associations = if depth > 0
204
+ Hash[
205
+ associations.map { |name, association|
206
+ association_hash = if association.is_a?(Array)
207
+ association.map { |item| item.to_hash(depth: depth - 1) }
208
+ else
209
+ association.to_hash(depth: depth - 1)
210
+ end
211
+ [name, association_hash]
212
+ }
213
+ ]
214
+ else
215
+ {}
216
+ end
217
+ attributes.merge(hashed_associations)
218
+ end
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,124 @@
1
+ require_relative "worksheet/column"
2
+ require_relative "worksheet/row"
3
+
4
+ module SheetsDB
5
+ class Worksheet
6
+ include Enumerable
7
+
8
+ attr_reader :spreadsheet, :google_drive_resource, :type
9
+
10
+ def initialize(spreadsheet:, google_drive_resource:, type:)
11
+ @spreadsheet = spreadsheet
12
+ @google_drive_resource = google_drive_resource
13
+ @type = type
14
+ end
15
+
16
+ def ==(other)
17
+ other.is_a?(self.class) &&
18
+ other.google_drive_resource == google_drive_resource &&
19
+ other.type == type
20
+ end
21
+
22
+ def columns
23
+ @columns ||= begin
24
+ {}.tap { |directory|
25
+ google_drive_resource.rows.first.each_with_index do |name, i|
26
+ unless name == ""
27
+ directory[name.to_sym] = Column.new(name: name.to_sym, column_position: i + 1)
28
+ end
29
+ end
30
+ }
31
+ end
32
+ end
33
+
34
+ def attribute_definitions
35
+ type.attribute_definitions
36
+ end
37
+
38
+ def attribute_at_row_position(column_name, row_position)
39
+ attribute_definition = attribute_definitions.fetch(column_name, {})
40
+ column = columns[column_name]
41
+ raw_value = read_value_from_google_drive_resource(
42
+ dimensions: [row_position, column.column_position],
43
+ attribute_definition: attribute_definition
44
+ )
45
+ end
46
+
47
+ def read_value_from_google_drive_resource(dimensions:, attribute_definition:)
48
+ raw_value = case attribute_definition[:type].to_s
49
+ when "DateTime"
50
+ google_drive_resource.input_value(*dimensions)
51
+ else
52
+ google_drive_resource[*dimensions]
53
+ end
54
+ if attribute_definition[:multiple]
55
+ raw_value.split(/,\s*/).map { |value| convert_value(value, attribute_definition) }
56
+ else
57
+ convert_value(raw_value, attribute_definition)
58
+ end
59
+ end
60
+
61
+ def update_attributes_at_row_position(staged_attributes, row_position:)
62
+ staged_attributes.each do |name, value|
63
+ column = columns[name]
64
+ definition = attribute_definitions[name]
65
+ assignment_value = definition[:multiple] ? value.join(",") : value
66
+ google_drive_resource[row_position, column.column_position] = assignment_value
67
+ end
68
+ google_drive_resource.synchronize
69
+ end
70
+
71
+ def each
72
+ return to_enum(:each) unless block_given?
73
+ (google_drive_resource.num_rows - 1).times do |i|
74
+ yield type.new(worksheet: self, row_position: i + 2)
75
+ end
76
+ end
77
+
78
+ def all
79
+ to_a
80
+ end
81
+
82
+ def find_by_id(id)
83
+ find_by_ids([id]).first
84
+ end
85
+
86
+ def find_by_ids(ids)
87
+ result = []
88
+ each do |model|
89
+ break if result.count == ids.count
90
+ if ids.include?(model.id)
91
+ result << model
92
+ end
93
+ end
94
+ result
95
+ end
96
+
97
+ def find_by_attribute(attribute_name, value)
98
+ definition = attribute_definitions[attribute_name]
99
+ select { |item|
100
+ attribute = item.send(attribute_name)
101
+ definition[:multiple] ? attribute.include?(value) : attribute == value
102
+ }
103
+ end
104
+
105
+ def reload!
106
+ google_drive_resource.reload
107
+ end
108
+
109
+ def convert_value(raw_value, attribute_definition)
110
+ return nil if raw_value == ""
111
+ converted_value = case attribute_definition[:type].to_s
112
+ when "Integer"
113
+ raw_value.to_i
114
+ when "DateTime"
115
+ DateTime.strptime(raw_value, "%m/%d/%Y %H:%M:%S")
116
+ else
117
+ raw_value
118
+ end
119
+ attribute_definition[:transform] ?
120
+ attribute_definition[:transform].call(converted_value) :
121
+ converted_value
122
+ end
123
+ end
124
+ end
data/lib/sheets_db.rb ADDED
@@ -0,0 +1,11 @@
1
+ require "sheets_db/version"
2
+ require "sheets_db/support"
3
+ require "sheets_db/session"
4
+ require "sheets_db/resource"
5
+ require "sheets_db/collection"
6
+ require "sheets_db/spreadsheet"
7
+ require "sheets_db/worksheet"
8
+
9
+ module SheetsDB
10
+ # Your code goes here...
11
+ end
data/sheets_db.gemspec ADDED
@@ -0,0 +1,27 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'sheets_db/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "sheets_db"
8
+ spec.version = SheetsDB::VERSION
9
+ spec.authors = ["Ravi Gadad"]
10
+ spec.email = ["ravi@ablschools.com"]
11
+
12
+ spec.summary = %q{Adapter for pseudo-relational data stored in Google Sheets}
13
+ spec.homepage = "https://github.com/ablschools/sheets_db"
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
17
+ spec.bindir = "exe"
18
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
19
+ spec.require_paths = ["lib"]
20
+
21
+ spec.add_dependency "google_drive", "~> 2.1"
22
+
23
+ spec.add_development_dependency "bundler", "~> 1.12"
24
+ spec.add_development_dependency "rake", "~> 10.0"
25
+ spec.add_development_dependency "rspec", "~> 3.0"
26
+ spec.add_development_dependency "simplecov"
27
+ end
metadata ADDED
@@ -0,0 +1,145 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sheets_db
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ravi Gadad
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2016-11-01 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: google_drive
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: bundler
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.12'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.12'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '10.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '10.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: simplecov
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ description:
84
+ email:
85
+ - ravi@ablschools.com
86
+ executables: []
87
+ extensions: []
88
+ extra_rdoc_files: []
89
+ files:
90
+ - ".gitignore"
91
+ - ".rspec"
92
+ - ".ruby-version"
93
+ - ".travis.yml"
94
+ - CODE_OF_CONDUCT.md
95
+ - Gemfile
96
+ - LICENSE.txt
97
+ - README.md
98
+ - Rakefile
99
+ - bin/console
100
+ - bin/setup
101
+ - documentation/example_models/organization.rb
102
+ - documentation/example_models/pet.rb
103
+ - documentation/example_models/project.rb
104
+ - documentation/example_models/task.rb
105
+ - documentation/example_models/team.rb
106
+ - documentation/example_models/user.rb
107
+ - documentation/images/example_erd.png
108
+ - documentation/images/example_spreadsheet_data.png
109
+ - documentation/usage.md
110
+ - lib/sheets_db.rb
111
+ - lib/sheets_db/collection.rb
112
+ - lib/sheets_db/resource.rb
113
+ - lib/sheets_db/session.rb
114
+ - lib/sheets_db/spreadsheet.rb
115
+ - lib/sheets_db/support.rb
116
+ - lib/sheets_db/version.rb
117
+ - lib/sheets_db/worksheet.rb
118
+ - lib/sheets_db/worksheet/column.rb
119
+ - lib/sheets_db/worksheet/row.rb
120
+ - sheets_db.gemspec
121
+ homepage: https://github.com/ablschools/sheets_db
122
+ licenses:
123
+ - MIT
124
+ metadata: {}
125
+ post_install_message:
126
+ rdoc_options: []
127
+ require_paths:
128
+ - lib
129
+ required_ruby_version: !ruby/object:Gem::Requirement
130
+ requirements:
131
+ - - ">="
132
+ - !ruby/object:Gem::Version
133
+ version: '0'
134
+ required_rubygems_version: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
139
+ requirements: []
140
+ rubyforge_project:
141
+ rubygems_version: 2.5.1
142
+ signing_key:
143
+ specification_version: 4
144
+ summary: Adapter for pseudo-relational data stored in Google Sheets
145
+ test_files: []