peel 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +14 -0
- data/.rspec +3 -0
- data/.travis.yml +7 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +37 -0
- data/LICENSE.txt +21 -0
- data/README.md +74 -0
- data/Rakefile +6 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/dev_journal/Day_0.md +109 -0
- data/dev_journal/Day_1.md +181 -0
- data/dev_journal/Day_2.md +59 -0
- data/dev_journal/Day_3.md +138 -0
- data/dev_journal/Day_4.md +195 -0
- data/dev_journal/Day_5..md +366 -0
- data/lib/peel.rb +22 -0
- data/lib/peel/modelable.rb +52 -0
- data/lib/peel/repository.rb +14 -0
- data/lib/peel/version.rb +3 -0
- data/peel.gemspec +31 -0
- metadata +123 -0
@@ -0,0 +1,59 @@
|
|
1
|
+
# Day 2
|
2
|
+
|
3
|
+
## Picking Up
|
4
|
+
|
5
|
+
It seems silly that it has taken me so long to realize that the goal isn't to create an Active Record object, it's to create an interface for dynamically adding Active Record behavior to a class.
|
6
|
+
|
7
|
+
I'm not sure I worded that well, so let me try to break it down, for my own sake.
|
8
|
+
|
9
|
+
In our class, `Book`, we shouldn't define a class-level method called `.find()`. Instead, this should be dynamically added to the ancestry chain, so that when we call `Book.find()`, and it doesn't exist inside `Book`, Ruby knows to look up the hierarchy to find where that method is defined.
|
10
|
+
|
11
|
+
Likewise for the instance methods, like `#update()`. Instead of "hardcoding" that method inside of the `Book` class, we'll want to establish an interface that will allow Ruby to delegate to where that method is defined.
|
12
|
+
|
13
|
+
With that said, the challenge of defining these methods, (`.find()`, `#update()`, etc.) continues. How do we define them in an abstract way so that the their behavior is adjusted during runtime? What interface do we make available to classes who wish to add this behavior?
|
14
|
+
|
15
|
+
## Behaviors
|
16
|
+
|
17
|
+
**Construct an instance of the Active Record from an SQL result set row.**
|
18
|
+
|
19
|
+
We want to define a class...
|
20
|
+
|
21
|
+
```ruby
|
22
|
+
class Book
|
23
|
+
end
|
24
|
+
```
|
25
|
+
|
26
|
+
Where we can call a method like `.find(1)`...
|
27
|
+
|
28
|
+
```ruby
|
29
|
+
Book.find(1)
|
30
|
+
```
|
31
|
+
|
32
|
+
Which will in turn make a call to `SQLite3` and execute a `SELECT` query...
|
33
|
+
|
34
|
+
```ruby
|
35
|
+
SQLite3::Database.new("books_app")
|
36
|
+
.execute("SELECT * FROM books WHERE id = 1;")
|
37
|
+
```
|
38
|
+
|
39
|
+
And return to us a `Book` instance that contains the data from the result set row, as attributes on the object.
|
40
|
+
|
41
|
+
```ruby
|
42
|
+
# => #<Book:0x007fa903965e98 @author="Metz, Sandi", @id=1, @isbn="9780321721334", @title="Practical Object Oriented Design in Ruby">
|
43
|
+
```
|
44
|
+
|
45
|
+
___
|
46
|
+
|
47
|
+
The bulk of the work in this flow lies in the `SQLite3` execute method. This method has a lot of knowledge:
|
48
|
+
|
49
|
+
1. `SQLite3::Database.new()` means it depends on the `SQLite3` library and knows how to get a new instance of a database object.
|
50
|
+
2. `"books_app"` is the name of the database.
|
51
|
+
3. `execute()` is the name of the method on the SQLite database instance that executes an SQL query.
|
52
|
+
4. `"SELECT * FROM ... WHERE ..."` is the SQL query string to be run.
|
53
|
+
5. `books` is the name of the database table.
|
54
|
+
6. `id` is that name of the column.
|
55
|
+
7. `1` is the id we're querying for.
|
56
|
+
|
57
|
+
Managing how all of that knowledge gets to our persistence layer is a vital part of this problem.
|
58
|
+
|
59
|
+
Another piece of the problem is how `Book` ends up with a `.find()` class method and how our persistence layer knows how to construct a new `Book` instance, once it's retrieved that data from the database.
|
@@ -0,0 +1,138 @@
|
|
1
|
+
# Day 3
|
2
|
+
|
3
|
+
## SQL Injection Vulnerability
|
4
|
+
|
5
|
+
```diff
|
6
|
+
class Book
|
7
|
+
...
|
8
|
+
|
9
|
+
def update(title: nil, author: nil, isbn: nil)
|
10
|
+
title = title || @title
|
11
|
+
author = author || @author
|
12
|
+
isbn = isbn || @isbn
|
13
|
+
|
14
|
+
- SQLite3::Database.new("books.db").execute <<-SQL
|
15
|
+
- UPDATE books
|
16
|
+
- SET title = '#{title}', author = '#{author}', isbn = '#{isbn}'
|
17
|
+
- WHERE id = #{@id};
|
18
|
+
-SQL
|
19
|
+
+ SQLite3::Database.new("books.db").execute(
|
20
|
+
+ "UPDATE books
|
21
|
+
+ SET title = ?, author = ?, isbn = ?
|
22
|
+
+ WHERE id = ?;",
|
23
|
+
+ [title, author, isbn, @id]
|
24
|
+
+ )
|
25
|
+
Book.new(id: @id, title: title, author: author, isbn: isbn)
|
26
|
+
end
|
27
|
+
|
28
|
+
...
|
29
|
+
end
|
30
|
+
```
|
31
|
+
|
32
|
+
Small update of the `Book#update()` method. The original version of this method used string interpolation in order to set the values for `title`, `author`, `isbn`, and `id`.
|
33
|
+
|
34
|
+
I did this because, the query is just a string and I needed that string to be "filled out" at runtime, dynamically. However, this has serious security implications. This is a **prime** example of how easy it is to introduce an [SQL injection vulnerability](https://www.owasp.org/index.php/SQL_Injection).
|
35
|
+
|
36
|
+
> A SQL injection attack consists of insertion or "injection" of a SQL query via the input data from the client to the application.
|
37
|
+
|
38
|
+
### The Attack
|
39
|
+
|
40
|
+
Our innocent code gets a book from the database, using the `Book#find()` method:
|
41
|
+
|
42
|
+
```ruby
|
43
|
+
book = Book.find(1)
|
44
|
+
#=> #<Book:0x007fc3c00f9160 @author="Metz, Sandi", @id=1, @isbn="0115501237044", @title="Practical Object-Oriented Design in Ruby">
|
45
|
+
```
|
46
|
+
|
47
|
+
When we want to update our book, we call `book.update()`, with the parameters we want to update. In this case, we update the title:
|
48
|
+
|
49
|
+
```ruby
|
50
|
+
book.update(title: "POODR")
|
51
|
+
#=> #<Book:0x007fc34ee1160 @author="Metz, Sandi", @id=1, @isbn="0115501237044", @title="POODR">
|
52
|
+
```
|
53
|
+
|
54
|
+
This is the expected behavior.
|
55
|
+
|
56
|
+
Next, to visualize what an attack looks like, lets remind ourselves of the method signature and SQL query:
|
57
|
+
|
58
|
+
```ruby
|
59
|
+
def update(title: nil, author: nil, isbn: nil)
|
60
|
+
...
|
61
|
+
"UPDATE books
|
62
|
+
SET title = '#{title}', author = '#{author}', isbn = '#{isbn}'
|
63
|
+
WHERE id = #{@id};"
|
64
|
+
...
|
65
|
+
end
|
66
|
+
```
|
67
|
+
|
68
|
+
Let's consider what would happen to our query if we called the `.update()` method like this:
|
69
|
+
|
70
|
+
```ruby
|
71
|
+
book.update(title: "PWNED' WHERE id = 2/*")
|
72
|
+
```
|
73
|
+
|
74
|
+
If we called our method like that, our query would look like this:
|
75
|
+
|
76
|
+
```sqlite
|
77
|
+
UPDATE books
|
78
|
+
SET title = 'PWNED' WHERE id = 2/*, author = 'Metz, Sandi', isbn = '0115501237044'
|
79
|
+
WHERE id = 1;
|
80
|
+
```
|
81
|
+
|
82
|
+
Notice that SQLite will consider everything after `/*` to be a comment, so if we strip that away, we're left with:
|
83
|
+
|
84
|
+
```sqlite
|
85
|
+
UPDATE books
|
86
|
+
SET title = 'PWNED'
|
87
|
+
WHERE id = 2;
|
88
|
+
```
|
89
|
+
|
90
|
+
What happened? Even though we were working with an Active Record object for a book row where `id = 1`, our interpolated string allowed us to effectively _change_ the original query in order to update a different row in our database, `WHERE id = 2` in our case.
|
91
|
+
|
92
|
+
We can see the effect of our SQL injection by querying for the book with and `id = 2`
|
93
|
+
|
94
|
+
```ruby
|
95
|
+
Book.find(2)
|
96
|
+
#=> #<Book:0x007fc3c20dd120 @author="Martin, Robert C.", @id=2, @isbn="0187123641198", @title="PWNED">
|
97
|
+
```
|
98
|
+
|
99
|
+
This is not the intended use of our client code, but once it's out there, we can't protect against this kind of attack. Imagine if we were updating a user password instead of a book title!
|
100
|
+
|
101
|
+
### The Fix
|
102
|
+
|
103
|
+
The fix is to use _parameterized queries_.
|
104
|
+
|
105
|
+
> The use of prepared statements with variable binding (aka parameterized queries) is how all developers should first be taught how to write database queries. They are simple to write, and easier to understand than dynamic queries. Parameterized queries force the developer to first define all the SQL code, and then pass in each parameter to the query later. This coding style allows the database to distinguish between code and data, regardless of what user input is supplied.
|
106
|
+
>
|
107
|
+
> [OWASP](https://www.owasp.org/index.php/SQL_Injection_Prevention_Cheat_Sheet#Defense_Option_1:_Prepared_Statements_.28Parameterized_Queries.29)
|
108
|
+
|
109
|
+
The `SQLite3` gem makes using these types of statements, really easy. We replace the interpolated code with a `?`, and provide a list of arguments that will be used to fill in those blanks, later:
|
110
|
+
|
111
|
+
```ruby
|
112
|
+
def update(title: nil, author: nil, isbn: nil)
|
113
|
+
...
|
114
|
+
SQLite3::Database.new("books.db").execute(
|
115
|
+
"UPDATE books SET title = ?, author = ?, isbn = ? WHERE id = ?",
|
116
|
+
[title, author, isbn, @id]
|
117
|
+
)
|
118
|
+
...
|
119
|
+
end
|
120
|
+
```
|
121
|
+
|
122
|
+
In our case, it's as simple as passing in a second argument to `#execute()`. Let's looks at that function definition:
|
123
|
+
|
124
|
+
```ruby
|
125
|
+
def execute(sql, bind_vars = [], *args, &block)
|
126
|
+
```
|
127
|
+
|
128
|
+
You can read more about [Binding Variables in SQLite](https://sqlite.org/c3ref/bind_blob.html), but essentially, the abstraction in the `SQLite3` gem does exactly what you imagine: replace each, `?`, with the corresponding value in the `bind_var` array, based on order.
|
129
|
+
|
130
|
+
If we try to do the same SQL injection now, we end up with this:
|
131
|
+
|
132
|
+
```ruby
|
133
|
+
book.update(title: "PWNED' WHERE id = 2/*")
|
134
|
+
#<Book:0x007fc3c22ff638 @author="Metz, Sandi", @id=1, @isbn="0115501237044", @title="PWNED' WHERE id = 2/*">
|
135
|
+
```
|
136
|
+
|
137
|
+
Instead of effecting any other rows in our database, the prepared statement escaped any characters which could otherwise be interpreted as SQL syntax.
|
138
|
+
|
@@ -0,0 +1,195 @@
|
|
1
|
+
# Day 4
|
2
|
+
|
3
|
+
## Adding Functionality to Client Classes
|
4
|
+
|
5
|
+
The first step in tackling this problem is to add functionality to a model class. If we have a class `Book`, we want to add a class method `Book.find` , and some instance methods `book#title`, `book#author`, and `book#isbn`, without needed to explicitly write them, like we did in the original spike.
|
6
|
+
|
7
|
+
#### A Note on Mix-Ins & Inheritance
|
8
|
+
|
9
|
+
When you want to create a model with `ActiveRecord`, you have to inherit from the `Base` module:
|
10
|
+
|
11
|
+
```ruby
|
12
|
+
class Book < ActiveRecord::Base; end
|
13
|
+
```
|
14
|
+
|
15
|
+
There are endless debates about composition v. inheritance, and, from my perspective, they all mostly lean towards composition, but what about using mix-ins versus using string inheritance?
|
16
|
+
|
17
|
+
In Ruby, inheriting and including a module _both_ add a new entity in the method look-up path:
|
18
|
+
|
19
|
+
**Inheritance**
|
20
|
+
|
21
|
+
```ruby
|
22
|
+
class Foo; end
|
23
|
+
class Bar < Foo; end
|
24
|
+
Bar.ancestors
|
25
|
+
#=> [Bar, Foo, Object, Kernel, BasicObject]
|
26
|
+
```
|
27
|
+
|
28
|
+
**Module Inclusion**
|
29
|
+
|
30
|
+
```ruby
|
31
|
+
module Foo; end
|
32
|
+
class Bar
|
33
|
+
include Foo
|
34
|
+
end
|
35
|
+
Bar.ancestors
|
36
|
+
#=> [Bar, Foo, Object, Kernel, BasicObject]
|
37
|
+
```
|
38
|
+
|
39
|
+
Which is what we want. When we think of the task at hand, we want to insert an object that defines `.find()` for a object we want to make an Active Record object.
|
40
|
+
|
41
|
+
For our intents and purposes, either solution will do, and perhaps the same could be said for the folks working on `ActiveRecord`. There are a few considerations to be made when deciding however; here's a quote from [Well Grounded Rubyist, 2nd Ed.](https://www.manning.com/books/the-well-grounded-rubyist-second-edition)
|
42
|
+
|
43
|
+
> - _Modules don't have instances._ It follows that entities or things are generally best modeled in classes, and characteristics or properties of entities or things are best encapsulated in modules... class names tend to be nouns, whereas module names are often adjectives...
|
44
|
+
> - _A class can have only one superclass, but it can mix in as many modules as it wants._ If you're using inheritance, give priority to creating a sensible superclass/subclass relationship. Don't use up a class's one and only superclass relationship to endow the class with what might turn out to be just one of several sets of characteristics.
|
45
|
+
|
46
|
+
With that said, I want to think of making client classes "model-able," rather than making client classes "a model." This pulls us towards using a module and including it, rather than using inheritance, _a la_ `ActiveRecord`.
|
47
|
+
|
48
|
+
#### A Note about Include & Extend
|
49
|
+
|
50
|
+
There are a few different ways to add behavior from a module into our classes. For our purposes, we'll focus on `include` and `extend`.
|
51
|
+
|
52
|
+
As mentioned above, `include` will add an entity in the method look-up path. So for our `book#title`, `book#author`, and `book#isbn`, this sounds like the kind of functionality we want. The users of our ORM shouldn't have to define these methods, they should be provided to them. If the method is missing from there class, we can define them in our module and place it next in line.
|
53
|
+
|
54
|
+
`extend` on the other hand, defines a singleton class and places the method definitions from our module there. This is the same as if the users of our ORM defined class methods in their class. This is a good candidate for `Book.find`, which, again, we'll define and place in singleton class of the client's class.
|
55
|
+
|
56
|
+
> Often, you will want to use a module to import instance methods on a class, but at the same time to define class methods. Normally, you would have to use two different modules, one with `include` to import instance methods, and another one with `extend` to define class methods.
|
57
|
+
>
|
58
|
+
> — [Léonard Hetsch](https://medium.com/@leo_hetsch/ruby-modules-include-vs-prepend-vs-extend-f09837a5b073)
|
59
|
+
|
60
|
+
The idiomatic way of doing this like so:
|
61
|
+
|
62
|
+
```ruby
|
63
|
+
module Foo
|
64
|
+
def self.included(base)
|
65
|
+
base.extend(ClassMethods)
|
66
|
+
end
|
67
|
+
|
68
|
+
module ClassMethods
|
69
|
+
def bar
|
70
|
+
"It works!"
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
class MyClass
|
76
|
+
include Foo
|
77
|
+
end
|
78
|
+
```
|
79
|
+
|
80
|
+
The hook `.included()` gets called whenever a module is included into a class, and it's passed the class that included it. The above code adds the method `.bar` to class `MyClass`'s singleton class, `#MyClass`:
|
81
|
+
|
82
|
+
```ruby
|
83
|
+
MyClass.bar
|
84
|
+
#=> "It works!"
|
85
|
+
```
|
86
|
+
|
87
|
+
## Bits & Bobs
|
88
|
+
|
89
|
+
Now we have all the bits & bobs to get started. We know we want to define `Book.find`, `book#title`, `book#author`, and `book#isbn`, but we'll also want to define a constructor `Book.new`, which Ruby has us do by defining the instance method `book#initialize`.
|
90
|
+
|
91
|
+
The workflow will go like this: we'll define a method in our client class, *e.g*. in `Book`, and then abstract those method out into modules.
|
92
|
+
|
93
|
+
## Onward to the Database
|
94
|
+
|
95
|
+
First, well want a way to read our database columns in order to know what accessor methods we need to build. In SQLite, we can use `PRAGMA`
|
96
|
+
|
97
|
+
> The PRAGMA statement is an SQL extension specific to SQLite and used to modify the operation of the SQLite library or to query the SQLite library for internal (non-table) data.
|
98
|
+
|
99
|
+
This works with the `sqlite3` gem like this:
|
100
|
+
|
101
|
+
```ruby
|
102
|
+
SQLite3::Database.new("books_app")
|
103
|
+
.execute("PRAGMA table_info(books)")
|
104
|
+
#=> [[0, "id", "INTEGER", 0, nil, 1], [1, "title", "VARCHAR(255)", 0, nil, 0], [2, "author", "VARCHAR(50)", 0, nil, 0], [3, "isbn", "VARCHAR(13)", 0, nil, 0]]
|
105
|
+
```
|
106
|
+
|
107
|
+
Each array has the following values:
|
108
|
+
|
109
|
+
```ruby
|
110
|
+
["cid", "name", "type", "notnull", "dflt_value", "pk"]
|
111
|
+
```
|
112
|
+
|
113
|
+
Because we want the "name" of each column, we can extract them like this:
|
114
|
+
|
115
|
+
```ruby
|
116
|
+
SQLite3::Database.new("books_app")
|
117
|
+
.execute("PRAGMA table_info(books)")
|
118
|
+
.map { |column| column[1].to_sym }
|
119
|
+
#=> [:id, :title, :author, :isbn]
|
120
|
+
```
|
121
|
+
|
122
|
+
Notice the call to `.to_sym` which turns each of the strings into Ruby symbols. This makes them a bit easy to work with later on.
|
123
|
+
|
124
|
+
Notice in our use of the `sqlite3` gem, there are two pieces of information that tightly couple this to our implementation: the database name, `books_app`, and the table name `books`.
|
125
|
+
|
126
|
+
**Note**: we're going to ignore the hardcoded database name, for now, and only address the table name.
|
127
|
+
|
128
|
+
Our solution is going to be to define an instance variable with the table name, and use that to build our query.
|
129
|
+
|
130
|
+
```ruby
|
131
|
+
class Book
|
132
|
+
@table_name = "books"
|
133
|
+
class << self
|
134
|
+
def columns
|
135
|
+
SQLite3::Database.new("books_app")
|
136
|
+
.execute("PRAGMA table_info(#{@table_name})")
|
137
|
+
.map { |column| column[1].to_sym }
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
141
|
+
```
|
142
|
+
|
143
|
+
`Book.columns` is a class method, because the columns will be the same for all instances of class `Book`. We could pass the table name into `.columns`, but likewise, the table name will be the same for all instances. However, we want to extract this into a module, and the table name will vary depending on the class that `include`s it. There are at least two ways to solve this problem.
|
144
|
+
|
145
|
+
`ActiveRecord` solves this by implicitly linking classes and tables based on the name of the class, _i.e._, a class of `Book` would be linked to a table `books`, and a class `User` would be linked to a table, `users`. Similar to the Broken Record project, I believe there's value in *explicitness*, so instead of inferring a table name, we'll pass it into our module and store it there as an instance variable.
|
146
|
+
|
147
|
+
```ruby
|
148
|
+
module Modelable
|
149
|
+
def self.included(base)
|
150
|
+
base.extend(ClassMethods)
|
151
|
+
end
|
152
|
+
|
153
|
+
module ClassMethods
|
154
|
+
def link_to(table_name)
|
155
|
+
@table_name = table_name.to_s
|
156
|
+
end
|
157
|
+
...
|
158
|
+
end
|
159
|
+
end
|
160
|
+
```
|
161
|
+
|
162
|
+
This allows us to do the following from our `Book` class:
|
163
|
+
|
164
|
+
```ruby
|
165
|
+
class Book
|
166
|
+
include Modelable
|
167
|
+
link_to(:books)
|
168
|
+
...
|
169
|
+
end
|
170
|
+
```
|
171
|
+
|
172
|
+
There is still an instance variable called `@table_name`, but we've assigned it using a method. That instance variable is available both to our `Book` class, as well as in the `Modelable` module, so we can now move the `#columns` class method into our module.
|
173
|
+
|
174
|
+
```ruby
|
175
|
+
module Modelable
|
176
|
+
def self.included(base)
|
177
|
+
base.extend(ClassMethods)
|
178
|
+
end
|
179
|
+
|
180
|
+
module ClassMethods
|
181
|
+
def link_to(table_name)
|
182
|
+
@table_name = table_name.to_s
|
183
|
+
end
|
184
|
+
|
185
|
+
def columns
|
186
|
+
SQLite3::Database.new("books_app")
|
187
|
+
.execute("PRAGMA table_info(#{@table_name})")
|
188
|
+
.map { |column| column[1].to_sym }
|
189
|
+
end
|
190
|
+
end
|
191
|
+
end
|
192
|
+
```
|
193
|
+
|
194
|
+
**Note**: Here were see string interpolation in an SQL query again, and we discussed how that produces a security vulnerability. Unfortunately, SQLite doesn't allow you to bind variables in PRAGMA queries. We could find the column names another way, using a query that _does_ allow us to use bound parameters, or we could even sanitize `@table_name` ourselves. For now, we'll make a note of it and place it in the icebox where stories go to die.
|
195
|
+
|
@@ -0,0 +1,366 @@
|
|
1
|
+
# Day 5
|
2
|
+
|
3
|
+
## Modelable
|
4
|
+
|
5
|
+
Yesterday, we left off with module, `Modelable`, that, when included, adds two class methods, `.link_to()`, and `.columns()`.
|
6
|
+
|
7
|
+
```ruby
|
8
|
+
module Modelable
|
9
|
+
def self.included(base)
|
10
|
+
base.extend(ClassMethods)
|
11
|
+
end
|
12
|
+
|
13
|
+
module ClassMethods
|
14
|
+
def link_to(table_name)
|
15
|
+
@table_name = table_name.to_s
|
16
|
+
end
|
17
|
+
|
18
|
+
def columns
|
19
|
+
SQLite3::Database.new("books_app")
|
20
|
+
.execute("PRAGMA table_info(#{@table_name})")
|
21
|
+
.map { |column| column[1].to_sym }
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
```
|
26
|
+
|
27
|
+
We use this module like this:
|
28
|
+
|
29
|
+
```ruby
|
30
|
+
class Book
|
31
|
+
include Modelable
|
32
|
+
link_to(:books)
|
33
|
+
end
|
34
|
+
|
35
|
+
Book.columns
|
36
|
+
#=> [:id, :title, :author, :isbn]
|
37
|
+
```
|
38
|
+
|
39
|
+
Notice, that if we don't call the `.link_to()` method, we would get an error:
|
40
|
+
|
41
|
+
```ruby
|
42
|
+
class Book
|
43
|
+
include Modelable
|
44
|
+
end
|
45
|
+
|
46
|
+
Book.columns
|
47
|
+
#=> SQLite3::SQLException: near ")": syntax error
|
48
|
+
```
|
49
|
+
|
50
|
+
Because we haven't defined the `@table_name` class instance variable, so the query is being interpolated with `nil` instead of a string.
|
51
|
+
|
52
|
+
**Note**: If `nil` were just an empty string, we would get back an empty list, rather than an exception. This is design choice to take into consideration. Perhaps if we were test driving this, this is the kind of behavior we would account for in our design.
|
53
|
+
|
54
|
+
Now that we have our list of column names, we want to create accessor methods for each of the columns. We can do this by creating instance variables for each column and then creating getter and setter methods that retrieve and set the instances variables, respectively.
|
55
|
+
|
56
|
+
### Metaprogramming
|
57
|
+
|
58
|
+
```ruby
|
59
|
+
class Book
|
60
|
+
columns.each do |column|
|
61
|
+
define_method("#{column}") { instance_variable_get("@#{column}")}
|
62
|
+
define_method("#{column}=") { |value| instance_variable_set("@#{column}", value)}
|
63
|
+
end
|
64
|
+
end
|
65
|
+
```
|
66
|
+
|
67
|
+
Ruby gives us access to metaprogramming, which I like to think of as just programming with meta-data. In this specific example, we use the `#define_method()` method, which does exactly like what is sounds like.
|
68
|
+
|
69
|
+
We pass into `#define_method()` the name of the method, and a block containing the body of the method. The two calls to `define_method()`, above, create a getter and a setter for each column. The body of the first retrieves and instance variable using `#instance_variable_get()`, and the second sets an instance variable using `#instance_variable_set()`.
|
70
|
+
|
71
|
+
An important thing to note here is that `#instance_variable_get()` will return `nil` if that instance variable hasn't been defined. So our getter method will either return a value that was set using the setter's `#instance_variable_set()`, or it will return `nil`.
|
72
|
+
|
73
|
+
**Note about `nil`**: It's important to realize the downfalls of using `nil` throughout a codebase in this way. `nil` represents both a _state_, (nothing has been set), as well as _data_, (the value of `foo` *is* `nil`, or `null`, as represented in the database). This conflation around `nil` is a topic I find really interesting, and has been tackled in functional languages with sum types; like Haskell's `Maybe` and Elixir's `Option`.
|
74
|
+
|
75
|
+
These calls to `#define_method()` are just plopped into the `Book` class, but we can just as easily move them to the `Modelable::ClassMethods` module and call them from our `.link_to` method:
|
76
|
+
|
77
|
+
```ruby
|
78
|
+
module Modelable
|
79
|
+
def self.included(base)
|
80
|
+
base.extend(ClassMethods)
|
81
|
+
end
|
82
|
+
|
83
|
+
module ClassMethods
|
84
|
+
def link_to(table_name)
|
85
|
+
@table_name = table_name.to_s
|
86
|
+
create_accessors
|
87
|
+
end
|
88
|
+
|
89
|
+
def create_accessors
|
90
|
+
columns.each do |column|
|
91
|
+
define_method("#{column}") { instance_variable_get("@#{column}")}
|
92
|
+
define_method("#{column}=") { |value| instance_variable_set("@#{column}", value)}
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
def columns
|
97
|
+
SQLite3::Database.new("books_app")
|
98
|
+
.execute("PRAGMA table_info(#{@table_name})")
|
99
|
+
.map { |column| column[1].to_sym }
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
```
|
104
|
+
|
105
|
+
Now, whenever a client class calls `.link_to()`, or module will retrieve the names of the column for the given table, and automatically generate getters/setters for each of them:
|
106
|
+
|
107
|
+
```ruby
|
108
|
+
class Book
|
109
|
+
include Modelable
|
110
|
+
link_to(:books)
|
111
|
+
end
|
112
|
+
|
113
|
+
book = Book.new
|
114
|
+
book.title
|
115
|
+
#=> nil
|
116
|
+
book.author
|
117
|
+
#=> nil
|
118
|
+
```
|
119
|
+
|
120
|
+
### Find
|
121
|
+
|
122
|
+
After creating some infrastructure in the client class, we've gotten a good pattern here with our modules. When we want to add a class instance method, we add it to the `Modelable::ClassMethods` module, when we want to add an instance method, we place in `Modelable` module.
|
123
|
+
|
124
|
+
If we think about our interface for `.find()`, we know we want to be able to call it on the class, e.g., `Book.find(1)`. And, we want that class instance method to return a new instance of `Book`, with all of the instances variables set to the values in the respective columns.
|
125
|
+
|
126
|
+
First, we'll tackle the retrieval of information from the database:
|
127
|
+
|
128
|
+
```ruby
|
129
|
+
def find(id)
|
130
|
+
SQLite3::Database.new("books_app")
|
131
|
+
.execute("SELECT * FROM #{table_name} WHERE id = ? LIMIT 1", id)
|
132
|
+
end
|
133
|
+
```
|
134
|
+
|
135
|
+
_Note: we have a similar issue as before with the string interpolation..._
|
136
|
+
|
137
|
+
Here we're selecting all the rows from the `books` for all rows that have an `id` that match the `id` we pass into the method. `SQLite3`'s `Database#execute()` method allows us to pass in bound arguments as an array as the second argument. Here, it also allows us to pass in just one variable as a single parameter.
|
138
|
+
|
139
|
+
By default, `SQLite3` returns the results in an array of arrays:
|
140
|
+
|
141
|
+
```ruby
|
142
|
+
[[2, "Practical Object-Oriented Design in Ruby", "Metz, Sandi", "0311237841549"]]
|
143
|
+
```
|
144
|
+
|
145
|
+
We can use this, with our `columns` array to create a key-value pair, but `SQLite3` also gives us access to a `Database#execute2()` method, which is exactly the same as `Database#execute()`, except that is returns the column names in an array, as well as the resulting values from the query:
|
146
|
+
|
147
|
+
```ruby
|
148
|
+
[
|
149
|
+
["id", "title", "author", "isbn"],
|
150
|
+
[2, "Practical Object-Oriented Design in Ruby", "Metz, Sandi", "0311237841549"]
|
151
|
+
]
|
152
|
+
```
|
153
|
+
|
154
|
+
This allows us to run one query, instead of two, to get the column names. Now we can `zip` the two arrays, and convert it into a hash:
|
155
|
+
|
156
|
+
```ruby
|
157
|
+
def find(id)
|
158
|
+
result = SQLite3::Database.new("books_app")
|
159
|
+
.execute2("SELECT * FROM #{table_name} WHERE id = ? LIMIT 1", id)
|
160
|
+
result[0].zip(result[1]).to_h
|
161
|
+
end
|
162
|
+
|
163
|
+
Book.find(2)
|
164
|
+
#=> {"id"=>2, "title"=>"Practical Object-Oriented Design in Ruby", "author"=>"Metz, Sandi", "isbn"=>"0311237841549"}
|
165
|
+
```
|
166
|
+
|
167
|
+
The last thing we'll want to take in account is if our query returns an empty result. If that is the case, we'll return an empty hash:
|
168
|
+
|
169
|
+
```ruby
|
170
|
+
def find(id)
|
171
|
+
result = SQLite3::Database.new("books_app")
|
172
|
+
.execute2("SELECT * FROM #{table_name} WHERE id = ? LIMIT 1", id)
|
173
|
+
|
174
|
+
if result.empty?
|
175
|
+
{}
|
176
|
+
else
|
177
|
+
result[0].zip(result[1]).to_h
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
Book.find(1337)
|
182
|
+
#=> {}
|
183
|
+
```
|
184
|
+
|
185
|
+
Like before, we can write this function in the `Modelable::ClassMethods` module, an it will be available to our client class, `Book`.
|
186
|
+
|
187
|
+
### Construction
|
188
|
+
|
189
|
+
Now that we have our hash, we're just a stone throw's away from having a new instance of `Book`. If we tried to pass our hash into `Book.new`, we'd get the following error:
|
190
|
+
|
191
|
+
```ruby
|
192
|
+
Book.new(Book.find(2))
|
193
|
+
ArgumentError: wrong number of arguments (given 1, expected 0)
|
194
|
+
```
|
195
|
+
|
196
|
+
That's because we haven't defined a constructor for `Book` that accepts a hash.
|
197
|
+
|
198
|
+
We could start out with something like this:
|
199
|
+
|
200
|
+
```ruby
|
201
|
+
class Book
|
202
|
+
def initialize(**args)
|
203
|
+
@id = args.fetch(:id, nil)
|
204
|
+
@title = args.fetch(:title, nil)
|
205
|
+
@author = args.fetch(:author, nil)
|
206
|
+
@isbn = args.fetch(:isbn, nil)
|
207
|
+
end
|
208
|
+
end
|
209
|
+
```
|
210
|
+
|
211
|
+
Now, if we pass in our hash, we get a new instance with the correct values!
|
212
|
+
|
213
|
+
```ruby
|
214
|
+
Book.new(Book.find(2))
|
215
|
+
#<Book:0x007f8b8fafe6b0 @author="Metz, Sandi", @id=2, @isbn="0311237841549", @title="Practical Object-Oriented Design in Ruby">
|
216
|
+
```
|
217
|
+
|
218
|
+
However, we want to abstract that our generalized `.find()` method can return an instance of _any_ class.
|
219
|
+
|
220
|
+
Instead of hardcoding instance variables, we can use the same `#instance_variable_set()` method we used earlier. We'll iterate over the hash, and for each key/column we'll create an instance variable and set it to the value/row data:
|
221
|
+
|
222
|
+
```ruby
|
223
|
+
def initialize(**args)
|
224
|
+
args.each do |attribute, value|
|
225
|
+
instance_variable_set("@#{attribute.to_s}, value)
|
226
|
+
end
|
227
|
+
end
|
228
|
+
```
|
229
|
+
|
230
|
+
Notice the `@` symbol, without it, our instance variables wouldn't have the conventional `@` in front of it.
|
231
|
+
|
232
|
+
Now, we can initialize our `Book` with a hash, and it will assign instance variables for each of the key-value pairs!
|
233
|
+
|
234
|
+
```ruby
|
235
|
+
Book.new(Book.find(2))
|
236
|
+
#<Book:0x007f8b8fafe6b0 @author="Metz, Sandi", @id=2, @isbn="0311237841549", @title="Practical Object-Oriented Design in Ruby">
|
237
|
+
```
|
238
|
+
|
239
|
+
We can also initialize our `Book` manually, like before:
|
240
|
+
|
241
|
+
```ruby
|
242
|
+
Book.new(title: "POODR")
|
243
|
+
#<Book:0x0078e21fafe6b0 @title="POODR">
|
244
|
+
```
|
245
|
+
|
246
|
+
However, we can also initialize _any_ instance variables we pass in:
|
247
|
+
|
248
|
+
```ruby
|
249
|
+
Book.new(foo: "bar")
|
250
|
+
#<Book:0x0078e21fafe6b0 @foo="bar">
|
251
|
+
```
|
252
|
+
|
253
|
+
To solve this, we can limit the instance variables based on the columns in the database, if we pass in a key that doesn't match a column, we will raise an `ArgumentError`:
|
254
|
+
|
255
|
+
```ruby
|
256
|
+
def initialize(**args)
|
257
|
+
args.each do |attribute, value|
|
258
|
+
if self.class.columns.include?(attribute)
|
259
|
+
instance_variable_set("@#{attribute.to_s}", value)
|
260
|
+
else
|
261
|
+
raise ArgumentError, "Unknown keyword: #{attribute}"
|
262
|
+
end
|
263
|
+
end
|
264
|
+
end
|
265
|
+
```
|
266
|
+
|
267
|
+
### Tying it All Together
|
268
|
+
|
269
|
+
The last step is to move this constructor into the `Modelable` module. We do not want to move it into the `Modelable::ClassMethods` module, even though we're defining the `.new` class method...
|
270
|
+
|
271
|
+
In ruby, when we define the _instance_ method, `#initialize()`, ruby will automagically call it after we call the _class_ method, `.new`.
|
272
|
+
|
273
|
+
The final step is to add a call to `.new` with the result of the call `.find()`.
|
274
|
+
|
275
|
+
Our entire module looks like this:
|
276
|
+
|
277
|
+
```ruby
|
278
|
+
module Modelable
|
279
|
+
def initialize(**args)
|
280
|
+
args.each do |attribute, value|
|
281
|
+
if self.class.columns.include?(attribute)
|
282
|
+
instance_variable_set("@#{attribute.to_s}", value)
|
283
|
+
else
|
284
|
+
raise ArgumentError, "Unknown keyword: #{attribute}"
|
285
|
+
end
|
286
|
+
end
|
287
|
+
end
|
288
|
+
|
289
|
+
def self.included(base)
|
290
|
+
base.extend(ClassMethods)
|
291
|
+
end
|
292
|
+
|
293
|
+
module ClassMethods
|
294
|
+
def link_to(table_name)
|
295
|
+
@table_name = table_name.to_s
|
296
|
+
create_accessors
|
297
|
+
end
|
298
|
+
|
299
|
+
def create_accessors
|
300
|
+
columns.each do |column|
|
301
|
+
define_method("#{column}") { instance_variable_get("@#{column}")}
|
302
|
+
define_method("#{column}=") { |value| instance_variable_set("@#{column}", value)}
|
303
|
+
end
|
304
|
+
end
|
305
|
+
|
306
|
+
def find(id)
|
307
|
+
result = SQLite3::Database.new("books_app")
|
308
|
+
.execute2("SELECT * FROM #{table_name} WHERE id = ? LIMIT 1", id)
|
309
|
+
|
310
|
+
if result.empty?
|
311
|
+
{}
|
312
|
+
else
|
313
|
+
result_hash = result[0].zip(result[1]).to_h
|
314
|
+
new(result_hash)
|
315
|
+
end
|
316
|
+
end
|
317
|
+
|
318
|
+
def columns
|
319
|
+
SQLite3::Database.new("books_app")
|
320
|
+
.execute("PRAGMA table_info(#{@table_name})")
|
321
|
+
.map { |column| column[1].to_sym }
|
322
|
+
end
|
323
|
+
end
|
324
|
+
end
|
325
|
+
```
|
326
|
+
|
327
|
+
### Grande Finale
|
328
|
+
|
329
|
+
We can now create Active Record objects with our module:
|
330
|
+
|
331
|
+
```ruby
|
332
|
+
class Book
|
333
|
+
include Modelable
|
334
|
+
link_to(:books)
|
335
|
+
end
|
336
|
+
|
337
|
+
book = Book.find(1)
|
338
|
+
#=> {"id"=>1, "title"=>"Practical Object-Oriented Design in Ruby", "author"=>"Metz, Sandi", "isbn"=>"0311237841549"}
|
339
|
+
|
340
|
+
book.title
|
341
|
+
#=> "Practical Object-Oriented Design in Ruby"
|
342
|
+
```
|
343
|
+
|
344
|
+
## Next Steps
|
345
|
+
|
346
|
+
There are quite a number of directions to go in with our new `Modelable` module, here are few key ones that interest me:
|
347
|
+
|
348
|
+
1. Abstracting the Database
|
349
|
+
|
350
|
+
We're relying directly on the `SQLite3` gem, which couples our implementation directly to SQLite. Even further, our implementation hardcodes the database `book_app`. Wrapping this library, abstracting a `Repository` object, and/or using a `Configuration` object, are all possible next steps. Moving towards database agnosticism is an even better move towards Rails' `ActiveRecord`
|
351
|
+
|
352
|
+
2. Tests
|
353
|
+
|
354
|
+
We didn't write any tests for `Modelable`, and that's because we couldn't see where we were going before we got there. Now is a good time to write some tests and drive this out again, or at least writing some integration tests that describe the current behavior.
|
355
|
+
|
356
|
+
3. Extend to Other Active Record Methods
|
357
|
+
|
358
|
+
We've implemented `.find()`, and have established a good abstraction patter in our module. It should be fairly straight forward to implement other common methods like `#save()`, `.all`, `#update()`, `#destroy`, etc. Soon, more patterns are sure to surface and lead to further abstractions of the module. This, of course, could really benefit from TDD!
|
359
|
+
|
360
|
+
4. Remove String Interpolation from SQL Queries
|
361
|
+
|
362
|
+
See [Day_3](./Day_3.md)
|
363
|
+
|
364
|
+
5. Replace `nil` with `Null` Objects?
|
365
|
+
|
366
|
+
Above, we talked about the downfalls of using `nil` to take on many meanings throughout the code base. Ruby is notorious for this, but often times the Null Object Pattern can step in and provide our `nil`s with more context. Perhaps this could be usefully employed to solve our `nil` problem.
|