sack 1.0.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 +3 -0
- data/Gemfile +2 -0
- data/LICENSE.txt +21 -0
- data/README.md +291 -0
- data/Rakefile +9 -0
- data/lib/sack.rb +14 -0
- data/lib/sack/connectors.rb +14 -0
- data/lib/sack/connectors/sqlite3.rb +46 -0
- data/lib/sack/database.rb +96 -0
- data/lib/sack/database/data.rb +141 -0
- data/lib/sack/database/ftypes.rb +31 -0
- data/lib/sack/database/generator.rb +54 -0
- data/lib/sack/database/model.rb +108 -0
- data/lib/sack/database/model/data.rb +66 -0
- data/lib/sack/database/model/relationships.rb +35 -0
- data/lib/sack/database/model/relationships/belongs_to.rb +62 -0
- data/lib/sack/database/model/relationships/has_many.rb +56 -0
- data/lib/sack/database/model/validation.rb +138 -0
- data/lib/sack/database/sanitizer.rb +78 -0
- data/lib/sack/database/schema.rb +32 -0
- data/lib/sack/database/statement.rb +27 -0
- data/lib/sack/version.rb +9 -0
- data/sack.gemspec +25 -0
- metadata +137 -0
@@ -0,0 +1,141 @@
|
|
1
|
+
# Sack
|
2
|
+
# by Eresse <eresse@eresse.net>
|
3
|
+
|
4
|
+
# External Includes
|
5
|
+
require 'aromat'
|
6
|
+
|
7
|
+
# Internal Includes
|
8
|
+
require 'sack/database/ftypes'
|
9
|
+
require 'sack/database/statement'
|
10
|
+
require 'sack/database/generator'
|
11
|
+
require 'sack/database/sanitizer'
|
12
|
+
|
13
|
+
# Sack Module
|
14
|
+
module Sack
|
15
|
+
|
16
|
+
# Database Class
|
17
|
+
class Database
|
18
|
+
|
19
|
+
# Data Class:
|
20
|
+
# Internal Class presented as Data Access Layer when yielding in Database.open.
|
21
|
+
class Data
|
22
|
+
|
23
|
+
# Construct:
|
24
|
+
# Builds a *Data* object around a _db_ instance, set to operate on _schema_.
|
25
|
+
# @param [SQLite3::Database] db Database instance obtained by opening an SQLite3 file
|
26
|
+
# @param [Hash] schema Schema definition - see README
|
27
|
+
def initialize db, schema
|
28
|
+
@db = db
|
29
|
+
@schema = schema
|
30
|
+
end
|
31
|
+
|
32
|
+
# Create Table:
|
33
|
+
# Creates a table _name_ defined by the _fields_ hash.
|
34
|
+
# @param [Symbol] name Table name
|
35
|
+
# @param [Hash] fields A hash mapping of field names to type definition arrays (from _FTYPES_)
|
36
|
+
def create_table name, fields
|
37
|
+
fq = fields.collect { |fname, ftype| "#{Sanitizer.field_name fname} #{ftype.respond_to?(:each) ? ftype.collect { |e| FTYPES[Sanitizer.ftype e] }.join(' ') : FTYPES[Sanitizer.ftype ftype]}" }.join ', '
|
38
|
+
@db.execute "create table #{Sanitizer.table @schema, name} (#{fq});"
|
39
|
+
end
|
40
|
+
|
41
|
+
# Count:
|
42
|
+
# Counts the number of rows for a given table.
|
43
|
+
# @param [Symbol] table Table name
|
44
|
+
# @return [Fixnum] The number of rows present in the given _table_
|
45
|
+
def count table
|
46
|
+
@db.execute("select count(*) from #{Sanitizer.table @schema, table};")[0][0]
|
47
|
+
end
|
48
|
+
|
49
|
+
# Find:
|
50
|
+
# Fetches the first matching row from a given _table_ by _id_.
|
51
|
+
def find table, id
|
52
|
+
fetch(table, id).try :first
|
53
|
+
end
|
54
|
+
|
55
|
+
# Fetch:
|
56
|
+
# Fetches rows from a given _table_ by _id_.
|
57
|
+
# @param [Symbol] table Table name
|
58
|
+
# @param [Object] id ID on which to filter
|
59
|
+
# @return [Array] An array of Hashes, each representing one row
|
60
|
+
def fetch table, id
|
61
|
+
hash_res(table.to_sym, @db.execute(Statement.prep("select * from #{Sanitizer.table @schema, table} where id = ?;", [id])))
|
62
|
+
end
|
63
|
+
|
64
|
+
# Fetch By Field:
|
65
|
+
# Fetches rows from a given _table_ where _field_ matches _val_.
|
66
|
+
# @param [Symbol] table Table name
|
67
|
+
# @param [Symbol] field Field name
|
68
|
+
# @param [Object] val Field value
|
69
|
+
# @return [Array] An array of Hashes, each representing one row
|
70
|
+
def fetch_by table, field, val
|
71
|
+
hash_res(table.to_sym, @db.execute(Statement.prep("select * from #{Sanitizer.table @schema, table} where #{Sanitizer.field @schema, table, field} = ?;", [val])))
|
72
|
+
end
|
73
|
+
|
74
|
+
# Fetch All:
|
75
|
+
# Fetches all rows from a given _table_.
|
76
|
+
# @param [Symbol] table Table name
|
77
|
+
# @return [Array] An array of Hashes, each representing one row
|
78
|
+
def fetch_all table
|
79
|
+
hash_res(table.to_sym, @db.execute("select * from #{Sanitizer.table @schema, table};"))
|
80
|
+
end
|
81
|
+
|
82
|
+
# Create:
|
83
|
+
# Inserts _fields_ as a row into a given _table_.
|
84
|
+
# @param [Symbol] table Table name
|
85
|
+
# @param [Hash] fields Fields to be inserted
|
86
|
+
def create table, fields
|
87
|
+
@db.execute Statement.prep("insert into #{Sanitizer.table @schema, table} (#{Generator.fields @schema, table, fields}) values (#{Generator.marks fields});", Generator.values(fields.values))
|
88
|
+
end
|
89
|
+
|
90
|
+
# Update:
|
91
|
+
# Updates _fields_ in rows identified by _id_ in a given _table_.
|
92
|
+
# @param [Symbol] table Table name
|
93
|
+
# @param [Object] id ID on which to filter
|
94
|
+
# @param [Hash] fields Fields to be updated
|
95
|
+
def update table, id, fields
|
96
|
+
@db.execute Statement.prep("update #{Sanitizer.table @schema, table} set #{Generator.update_marks @schema, table, fields} where id = ?;", [Generator.values(fields.values), id].flatten)
|
97
|
+
end
|
98
|
+
|
99
|
+
# Save:
|
100
|
+
# Creates or updates _fields_ in a given _table_, depending on the presence of an _id_.
|
101
|
+
# @param [Symbol] table Table name
|
102
|
+
# @param [Hash] fields Fields to be inserted / updated
|
103
|
+
def save table, fields
|
104
|
+
if fields[:id]
|
105
|
+
update table, fields.clone.delete(:id), fields
|
106
|
+
else
|
107
|
+
create table, fields
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
# Destroy:
|
112
|
+
# Removes rows identified by _id_ from a given _table_.
|
113
|
+
# @param [Symbol] table Table name
|
114
|
+
# @param [Object] id ID of rows to be removed
|
115
|
+
def delete table, id
|
116
|
+
@db.execute Statement.prep("delete from #{Sanitizer.table @schema, table} where id = ?;", [id])
|
117
|
+
end
|
118
|
+
|
119
|
+
# Execute statement:
|
120
|
+
# Generic method to execute any SQL statement.
|
121
|
+
# @param [String] q Statement to be executed
|
122
|
+
# @param [Array] params Statement parameters
|
123
|
+
# @return [Object] Whatever the statement returned
|
124
|
+
def exec q, params = []
|
125
|
+
@db.execute Statement.prep(q, params)
|
126
|
+
end
|
127
|
+
|
128
|
+
# Pull Results into Hash:
|
129
|
+
# Converts rows returned by SQLite3 into Hashes matching the provided schema.
|
130
|
+
# @param [Symbol] table Table name
|
131
|
+
# @param [Array] x Results returned by SQLite3
|
132
|
+
# @return [Array] An array of Hashes, each representing a single row
|
133
|
+
def hash_res table, x
|
134
|
+
x
|
135
|
+
.collect { |r| @schema[table].keys.each_with_index.inject([]) { |a, e| a + [e[0], r[e[1]]] } }
|
136
|
+
.collect { |r| Hash[*r] }
|
137
|
+
.sym_keys rescue nil
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# Sack
|
2
|
+
# by Eresse <eresse@eresse.net>
|
3
|
+
|
4
|
+
# Sack Module
|
5
|
+
module Sack
|
6
|
+
|
7
|
+
# Database Class
|
8
|
+
class Database
|
9
|
+
|
10
|
+
# Field Types:
|
11
|
+
# Available field types to be used when defining schemas.
|
12
|
+
FTYPES = {
|
13
|
+
str: 'VARCHAR(255)',
|
14
|
+
txt: 'TEXT',
|
15
|
+
int: 'INTEGER',
|
16
|
+
flo: 'REAL',
|
17
|
+
uniq: 'UNIQUE',
|
18
|
+
pk: 'PRIMARY KEY',
|
19
|
+
ai: 'AUTOINCREMENT'
|
20
|
+
}
|
21
|
+
|
22
|
+
# Ruby Type Conversions:
|
23
|
+
# Maps Field Types to Ruby Classes.
|
24
|
+
FTYPES_CLASSES = {
|
25
|
+
str: String,
|
26
|
+
txt: String,
|
27
|
+
int: Fixnum,
|
28
|
+
flo: Float
|
29
|
+
}
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
# Sack
|
2
|
+
# by Eresse <eresse@eresse.net>
|
3
|
+
|
4
|
+
# Internal Includes
|
5
|
+
require 'sack/database/sanitizer'
|
6
|
+
|
7
|
+
# Sack Module
|
8
|
+
module Sack
|
9
|
+
|
10
|
+
# Database Class
|
11
|
+
class Database
|
12
|
+
|
13
|
+
# Generator Module:
|
14
|
+
# Provides SQL generator methods for building queries.
|
15
|
+
module Generator
|
16
|
+
|
17
|
+
# Generate Field Name List:
|
18
|
+
# Builds a list of field names present in _field_hash_, separated by commas.
|
19
|
+
# @param [Hash] schema Database schema
|
20
|
+
# @param [Symbol] table Table name
|
21
|
+
# @param [Hash] field_hash Hash containing field names as keys
|
22
|
+
# @return [String] A comma-separated list of field names
|
23
|
+
def self.fields schema, table, field_hash
|
24
|
+
field_hash.keys.collect { |k| Sanitizer.field schema, table, k }.join ', '
|
25
|
+
end
|
26
|
+
|
27
|
+
# Value Marks:
|
28
|
+
# Builds a list of field markers ('?') for fields present in _field_hash_, separated by commas.
|
29
|
+
# @param [Hash] field_hash Hash containing fields
|
30
|
+
# @return [String] A comma-separated list of field markers ('?')
|
31
|
+
def self.marks field_hash
|
32
|
+
(['?'] * field_hash.size).join ', '
|
33
|
+
end
|
34
|
+
|
35
|
+
# Update Marks:
|
36
|
+
# Builds a list of field value markers ('field = ?') for fields present in _field_hash_, separated by commas.
|
37
|
+
# @param [Hash] schema Database schema
|
38
|
+
# @param [Symbol] table Table name
|
39
|
+
# @param [Hash] field_hash Hash containing field names as keys
|
40
|
+
# @return [String] A comma-separated list of field value markers ('field = ?')
|
41
|
+
def self.update_marks schema, table, field_hash
|
42
|
+
field_hash.keys.collect { |k| "#{Sanitizer.field schema, table, k} = ?" }.join ', '
|
43
|
+
end
|
44
|
+
|
45
|
+
# Values:
|
46
|
+
# Replaces all symbols in _vals_ by their stringified values.
|
47
|
+
# @param [Array] vals Array of value objects
|
48
|
+
# @return [Array] A copy of _vals_ where every symbol has been replaced by its string representation
|
49
|
+
def self.values vals
|
50
|
+
vals.collect { |v| v.is_a?(Symbol) ? v.to_s : v }
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,108 @@
|
|
1
|
+
# Sack
|
2
|
+
# by Eresse <eresse@eresse.net>
|
3
|
+
|
4
|
+
# External Includes
|
5
|
+
require 'aromat'
|
6
|
+
|
7
|
+
# Internal Includes
|
8
|
+
require 'sack/database/ftypes'
|
9
|
+
require 'sack/database/sanitizer'
|
10
|
+
require 'sack/database/model/data'
|
11
|
+
require 'sack/database/model/validation'
|
12
|
+
require 'sack/database/model/relationships'
|
13
|
+
|
14
|
+
# Sack Module
|
15
|
+
module Sack
|
16
|
+
|
17
|
+
# Database Class
|
18
|
+
class Database
|
19
|
+
|
20
|
+
# Model Module:
|
21
|
+
# Provides abstractions for defining database models.
|
22
|
+
module Model
|
23
|
+
|
24
|
+
# Included:
|
25
|
+
# Inject stuff when included.
|
26
|
+
# @param [Object] base Whatever we've been included into
|
27
|
+
def self.included base
|
28
|
+
|
29
|
+
# Set Model
|
30
|
+
base.instance_variable_set '@model', base
|
31
|
+
|
32
|
+
# Link to parent Data Model Root
|
33
|
+
base.instance_variable_set '@model_root', base.mod_parent
|
34
|
+
|
35
|
+
# Set Model Name
|
36
|
+
base.instance_variable_set '@model_name', base.mod_name
|
37
|
+
|
38
|
+
# Extend Class Methods
|
39
|
+
base.extend ClassMethods
|
40
|
+
|
41
|
+
# Extend with Data Access Methods
|
42
|
+
base.extend Data
|
43
|
+
|
44
|
+
# Include Validation
|
45
|
+
base.include Validation
|
46
|
+
|
47
|
+
# Include Relationships
|
48
|
+
base.include Relationships
|
49
|
+
end
|
50
|
+
|
51
|
+
# Class Methods:
|
52
|
+
# Collection of methods to be injected into anything that includes this module.
|
53
|
+
module ClassMethods
|
54
|
+
|
55
|
+
# Get / Set Table Name:
|
56
|
+
# Determines the default table name through introspection, or overrides the default table name (if _name_ is provided).
|
57
|
+
# @param [Symbol] name Override table name with this
|
58
|
+
# @return [Symbol] The table name
|
59
|
+
def table_name name = nil
|
60
|
+
|
61
|
+
# Introspect Default Name
|
62
|
+
@table_name ||= self.mod_name.snakecase
|
63
|
+
|
64
|
+
# Override with custom name
|
65
|
+
@table_name = name if name
|
66
|
+
|
67
|
+
@table_name.to_sym
|
68
|
+
end
|
69
|
+
|
70
|
+
# Set Field:
|
71
|
+
# Configures a field on the current Model.
|
72
|
+
# @param [Hash] options Options defining the field - see README
|
73
|
+
def field options
|
74
|
+
|
75
|
+
# Internalise Options (so we can mess it up)
|
76
|
+
options = options.clone
|
77
|
+
|
78
|
+
# Pull Name
|
79
|
+
name, ftype = options.first
|
80
|
+
options.delete name
|
81
|
+
|
82
|
+
# Check Primary Type
|
83
|
+
raise "Invalid Field Primary Type [#{ftype.first}] for [#{name}] in #{mod_name}" unless FTYPES_CLASSES.include? ftype.first
|
84
|
+
|
85
|
+
# Collect Validation Rules
|
86
|
+
@fields ||= {}
|
87
|
+
@fields[name] ||= {}
|
88
|
+
@fields[name][:ftype] = ftype
|
89
|
+
@fields[name][:rules] = options
|
90
|
+
end
|
91
|
+
|
92
|
+
# Get Fields:
|
93
|
+
# Simply returns the model's field map.
|
94
|
+
# @return [Hash] The field map for the current model
|
95
|
+
def fields
|
96
|
+
@fields
|
97
|
+
end
|
98
|
+
|
99
|
+
# Get Field Schema:
|
100
|
+
# Builds a schema for the current model.
|
101
|
+
# @return [Hash] The current model's schema
|
102
|
+
def field_schema
|
103
|
+
Hash[*(@fields.inject([]) { |a, e| a << e[0] << e[1][:ftype] })]
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
# Sack
|
2
|
+
# by Eresse <eresse@eresse.net>
|
3
|
+
|
4
|
+
# External Includes
|
5
|
+
require 'aromat'
|
6
|
+
|
7
|
+
# Internal Includes
|
8
|
+
require 'sack/database'
|
9
|
+
require 'sack/database/model/validation'
|
10
|
+
|
11
|
+
# Sack Module
|
12
|
+
module Sack
|
13
|
+
|
14
|
+
# Database Class
|
15
|
+
class Database
|
16
|
+
|
17
|
+
# Model Module
|
18
|
+
module Model
|
19
|
+
|
20
|
+
# Data Module:
|
21
|
+
# Provides a simple data access layer through models.
|
22
|
+
module Data
|
23
|
+
|
24
|
+
# Actions requiring Validation
|
25
|
+
VALIDATED_ACTIONS = [
|
26
|
+
:create,
|
27
|
+
:update,
|
28
|
+
:save
|
29
|
+
]
|
30
|
+
|
31
|
+
# Method Missing:
|
32
|
+
# Catches and routes model actions through database.
|
33
|
+
def method_missing name, *args
|
34
|
+
|
35
|
+
# Check action allowed
|
36
|
+
super name, *args unless ACTIONS.include? name
|
37
|
+
|
38
|
+
# Acquire Database
|
39
|
+
db = args.slice! 0
|
40
|
+
|
41
|
+
# Check Validation Required
|
42
|
+
if VALIDATED_ACTIONS.include? name
|
43
|
+
|
44
|
+
# Acquire Entity
|
45
|
+
data = args.last
|
46
|
+
|
47
|
+
# Pre-load for updates
|
48
|
+
data = data.clone.merge find(db, args.slice(0)) if name == :update
|
49
|
+
|
50
|
+
# Validate
|
51
|
+
errors = []
|
52
|
+
raise Validation::ValidationException.new "Invalid Entity [#{data}] for Model #{@model}", errors unless is_valid? db, data, errors
|
53
|
+
end
|
54
|
+
|
55
|
+
# Forward to Database
|
56
|
+
result = db.send name, table_name, *args
|
57
|
+
|
58
|
+
# Inject Model Proxy into Entity/ies
|
59
|
+
(result.is_a?(Array) ? result : [result]).each { |e| e.instance_variable_set('@model_mod', self); e.define_singleton_method(:method_missing) { |n, *a| @model_mod.send n, *a, self } }
|
60
|
+
|
61
|
+
result
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
# Sack
|
2
|
+
# by Eresse <eresse@eresse.net>
|
3
|
+
|
4
|
+
# Internal Includes
|
5
|
+
require 'sack/database/model/relationships/has_many'
|
6
|
+
require 'sack/database/model/relationships/belongs_to'
|
7
|
+
|
8
|
+
# Sack Module
|
9
|
+
module Sack
|
10
|
+
|
11
|
+
# Database Class
|
12
|
+
class Database
|
13
|
+
|
14
|
+
# Model Module
|
15
|
+
module Model
|
16
|
+
|
17
|
+
# Relationships Module:
|
18
|
+
# Provides Model Relationships.
|
19
|
+
module Relationships
|
20
|
+
|
21
|
+
# Included:
|
22
|
+
# Inject Relationship Methods when included.
|
23
|
+
# @param [Object] base Whatever we've been included into
|
24
|
+
def self.included base
|
25
|
+
base.extend ClassMethods
|
26
|
+
end
|
27
|
+
|
28
|
+
# Class Methods:
|
29
|
+
# Collection of methods to be injected into anything that includes this module.
|
30
|
+
module ClassMethods
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
# Sack
|
2
|
+
# by Eresse <eresse@eresse.net>
|
3
|
+
|
4
|
+
# Sack Module
|
5
|
+
module Sack
|
6
|
+
|
7
|
+
# Database Class
|
8
|
+
class Database
|
9
|
+
|
10
|
+
# Model Module
|
11
|
+
module Model
|
12
|
+
|
13
|
+
# Relationships Module
|
14
|
+
module Relationships
|
15
|
+
|
16
|
+
# Class Methods
|
17
|
+
module ClassMethods
|
18
|
+
|
19
|
+
# Belongs To:
|
20
|
+
# Defines the 'slave' end of any relationship (the one that holds the other entity's ID).
|
21
|
+
# @param [Object] options Either a Symbol indicating both the name of the relationship and the target model, or an option Hash defining the relationship - see README
|
22
|
+
def belongs_to options
|
23
|
+
|
24
|
+
# Check Options
|
25
|
+
if options.is_a? Hash
|
26
|
+
|
27
|
+
# Internalise Options (so we can mess it up)
|
28
|
+
options = options.clone
|
29
|
+
|
30
|
+
# Pull Relationship Name & Target Model
|
31
|
+
name, target_model_name = options.first
|
32
|
+
options.delete name
|
33
|
+
else
|
34
|
+
|
35
|
+
# Acquire everything from just a Symbol
|
36
|
+
name = options
|
37
|
+
target_model_name = name
|
38
|
+
end
|
39
|
+
|
40
|
+
# Construct Proxy Method Module
|
41
|
+
proxy = Module.new do
|
42
|
+
|
43
|
+
# Proxy Method for Relationship:
|
44
|
+
# Allows fetching entities for a given belongs-to relationship.
|
45
|
+
define_method name do |db, data|
|
46
|
+
|
47
|
+
# Find Target Model
|
48
|
+
target_model = @model_root.const_get target_model_name.to_s.camelcase
|
49
|
+
|
50
|
+
# Fetch Relationship Entity
|
51
|
+
target_model.find db, data[name]
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
# Extend Model with Proxy
|
56
|
+
@model.extend proxy
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|