tarsier 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/CHANGELOG.md +175 -0
- data/LICENSE.txt +21 -0
- data/README.md +984 -0
- data/exe/tarsier +7 -0
- data/lib/tarsier/application.rb +336 -0
- data/lib/tarsier/cli/commands/console.rb +87 -0
- data/lib/tarsier/cli/commands/generate.rb +85 -0
- data/lib/tarsier/cli/commands/help.rb +50 -0
- data/lib/tarsier/cli/commands/new.rb +59 -0
- data/lib/tarsier/cli/commands/routes.rb +139 -0
- data/lib/tarsier/cli/commands/server.rb +123 -0
- data/lib/tarsier/cli/commands/version.rb +14 -0
- data/lib/tarsier/cli/generators/app.rb +528 -0
- data/lib/tarsier/cli/generators/base.rb +93 -0
- data/lib/tarsier/cli/generators/controller.rb +91 -0
- data/lib/tarsier/cli/generators/middleware.rb +81 -0
- data/lib/tarsier/cli/generators/migration.rb +109 -0
- data/lib/tarsier/cli/generators/model.rb +109 -0
- data/lib/tarsier/cli/generators/resource.rb +27 -0
- data/lib/tarsier/cli/loader.rb +18 -0
- data/lib/tarsier/cli.rb +46 -0
- data/lib/tarsier/controller.rb +282 -0
- data/lib/tarsier/database.rb +588 -0
- data/lib/tarsier/errors.rb +77 -0
- data/lib/tarsier/middleware/base.rb +47 -0
- data/lib/tarsier/middleware/compression.rb +113 -0
- data/lib/tarsier/middleware/cors.rb +101 -0
- data/lib/tarsier/middleware/csrf.rb +88 -0
- data/lib/tarsier/middleware/logger.rb +74 -0
- data/lib/tarsier/middleware/rate_limit.rb +110 -0
- data/lib/tarsier/middleware/stack.rb +143 -0
- data/lib/tarsier/middleware/static.rb +124 -0
- data/lib/tarsier/model.rb +590 -0
- data/lib/tarsier/params.rb +269 -0
- data/lib/tarsier/query.rb +495 -0
- data/lib/tarsier/request.rb +274 -0
- data/lib/tarsier/response.rb +282 -0
- data/lib/tarsier/router/compiler.rb +173 -0
- data/lib/tarsier/router/node.rb +97 -0
- data/lib/tarsier/router/route.rb +119 -0
- data/lib/tarsier/router.rb +272 -0
- data/lib/tarsier/version.rb +5 -0
- data/lib/tarsier/websocket.rb +275 -0
- data/lib/tarsier.rb +167 -0
- data/sig/tarsier.rbs +485 -0
- metadata +230 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Tarsier
|
|
6
|
+
module Middleware
|
|
7
|
+
# Static file serving middleware
|
|
8
|
+
# Serves files from a public directory with caching support
|
|
9
|
+
class Static < Base
|
|
10
|
+
MIME_TYPES = {
|
|
11
|
+
".html" => "text/html",
|
|
12
|
+
".htm" => "text/html",
|
|
13
|
+
".css" => "text/css",
|
|
14
|
+
".js" => "application/javascript",
|
|
15
|
+
".json" => "application/json",
|
|
16
|
+
".xml" => "application/xml",
|
|
17
|
+
".txt" => "text/plain",
|
|
18
|
+
".png" => "image/png",
|
|
19
|
+
".jpg" => "image/jpeg",
|
|
20
|
+
".jpeg" => "image/jpeg",
|
|
21
|
+
".gif" => "image/gif",
|
|
22
|
+
".svg" => "image/svg+xml",
|
|
23
|
+
".ico" => "image/x-icon",
|
|
24
|
+
".webp" => "image/webp",
|
|
25
|
+
".woff" => "font/woff",
|
|
26
|
+
".woff2" => "font/woff2",
|
|
27
|
+
".ttf" => "font/ttf",
|
|
28
|
+
".eot" => "application/vnd.ms-fontobject",
|
|
29
|
+
".otf" => "font/otf",
|
|
30
|
+
".pdf" => "application/pdf",
|
|
31
|
+
".zip" => "application/zip",
|
|
32
|
+
".mp3" => "audio/mpeg",
|
|
33
|
+
".mp4" => "video/mp4",
|
|
34
|
+
".webm" => "video/webm"
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
37
|
+
DEFAULT_OPTIONS = {
|
|
38
|
+
root: "public",
|
|
39
|
+
urls: ["/"],
|
|
40
|
+
index: "index.html",
|
|
41
|
+
cache_control: "public, max-age=31536000",
|
|
42
|
+
headers: {}
|
|
43
|
+
}.freeze
|
|
44
|
+
|
|
45
|
+
def initialize(app, **options)
|
|
46
|
+
super
|
|
47
|
+
@options = DEFAULT_OPTIONS.merge(options)
|
|
48
|
+
@root = File.expand_path(@options[:root])
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def call(request, response)
|
|
52
|
+
return @app.call(request, response) unless servable?(request)
|
|
53
|
+
|
|
54
|
+
path = file_path(request.path)
|
|
55
|
+
return @app.call(request, response) unless path && File.file?(path)
|
|
56
|
+
|
|
57
|
+
serve_file(path, request, response)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def servable?(request)
|
|
63
|
+
return false unless request.get? || request.head?
|
|
64
|
+
|
|
65
|
+
@options[:urls].any? { |url| request.path.start_with?(url) }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def file_path(request_path)
|
|
69
|
+
# Prevent directory traversal
|
|
70
|
+
path = File.join(@root, request_path)
|
|
71
|
+
path = File.join(path, @options[:index]) if File.directory?(path)
|
|
72
|
+
|
|
73
|
+
# Ensure path is within root
|
|
74
|
+
return nil unless path.start_with?(@root)
|
|
75
|
+
|
|
76
|
+
path
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def serve_file(path, request, response)
|
|
80
|
+
stat = File.stat(path)
|
|
81
|
+
etag = generate_etag(path, stat)
|
|
82
|
+
last_modified = stat.mtime.httpdate
|
|
83
|
+
|
|
84
|
+
# Check conditional request headers
|
|
85
|
+
if fresh?(request, etag, last_modified)
|
|
86
|
+
response.status = 304
|
|
87
|
+
response.body = ""
|
|
88
|
+
return response
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
response.status = 200
|
|
92
|
+
response.set_header("Content-Type", mime_type(path))
|
|
93
|
+
response.set_header("Content-Length", stat.size.to_s)
|
|
94
|
+
response.set_header("Last-Modified", last_modified)
|
|
95
|
+
response.set_header("ETag", etag)
|
|
96
|
+
response.set_header("Cache-Control", @options[:cache_control])
|
|
97
|
+
|
|
98
|
+
@options[:headers].each { |k, v| response.set_header(k, v) }
|
|
99
|
+
|
|
100
|
+
response.body = request.head? ? "" : File.read(path)
|
|
101
|
+
response
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def fresh?(request, etag, last_modified)
|
|
105
|
+
if_none_match = request.header("If-None-Match")
|
|
106
|
+
if_modified_since = request.header("If-Modified-Since")
|
|
107
|
+
|
|
108
|
+
return true if if_none_match == etag
|
|
109
|
+
return true if if_modified_since == last_modified
|
|
110
|
+
|
|
111
|
+
false
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def generate_etag(path, stat)
|
|
115
|
+
%("#{Digest::MD5.hexdigest("#{path}#{stat.mtime.to_i}#{stat.size}")}")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def mime_type(path)
|
|
119
|
+
ext = File.extname(path).downcase
|
|
120
|
+
MIME_TYPES[ext] || "application/octet-stream"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarsier
|
|
4
|
+
# Lightweight Active Record pattern implementation
|
|
5
|
+
#
|
|
6
|
+
# Provides a clean, minimal ORM with support for SQLite, PostgreSQL,
|
|
7
|
+
# and MySQL. Designed for simplicity without sacrificing functionality.
|
|
8
|
+
#
|
|
9
|
+
# @example Define a model
|
|
10
|
+
# class User < Tarsier::Model
|
|
11
|
+
# table :users
|
|
12
|
+
#
|
|
13
|
+
# attribute :name, :string
|
|
14
|
+
# attribute :email, :string
|
|
15
|
+
# attribute :age, :integer
|
|
16
|
+
#
|
|
17
|
+
# validates :email, presence: true, format: /@/
|
|
18
|
+
#
|
|
19
|
+
# has_many :posts
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# @example Query records
|
|
23
|
+
# User.all
|
|
24
|
+
# User.find(1)
|
|
25
|
+
# User.where(active: true).order(:name).limit(10)
|
|
26
|
+
#
|
|
27
|
+
# @since 0.1.0
|
|
28
|
+
class Model
|
|
29
|
+
class << self
|
|
30
|
+
# Set or get the table name
|
|
31
|
+
#
|
|
32
|
+
# @param name [String, Symbol, nil] table name
|
|
33
|
+
# @return [String]
|
|
34
|
+
#
|
|
35
|
+
# @example
|
|
36
|
+
# table :users
|
|
37
|
+
def table(name = nil)
|
|
38
|
+
@table_name = name.to_s if name
|
|
39
|
+
@table_name ||= "#{self.name.split('::').last.downcase}s"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
alias table_name table
|
|
43
|
+
|
|
44
|
+
# Set or get the primary key
|
|
45
|
+
#
|
|
46
|
+
# @param key [Symbol, nil] primary key column
|
|
47
|
+
# @return [Symbol]
|
|
48
|
+
def primary_key(key = nil)
|
|
49
|
+
@primary_key = key if key
|
|
50
|
+
@primary_key ||= :id
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Define an attribute
|
|
54
|
+
#
|
|
55
|
+
# @param name [Symbol] attribute name
|
|
56
|
+
# @param type [Symbol] attribute type (:string, :integer, :boolean, etc.)
|
|
57
|
+
# @param options [Hash] attribute options
|
|
58
|
+
#
|
|
59
|
+
# @example
|
|
60
|
+
# attribute :name, :string
|
|
61
|
+
# attribute :age, :integer, default: 0
|
|
62
|
+
# attribute :active, :boolean, default: true
|
|
63
|
+
def attribute(name, type, **options)
|
|
64
|
+
attributes[name] = { type: type, **options }
|
|
65
|
+
|
|
66
|
+
define_method(name) { @attributes[name] }
|
|
67
|
+
define_method("#{name}=") do |value|
|
|
68
|
+
@attributes[name] = self.class.coerce(value, type)
|
|
69
|
+
@dirty << name unless @new_record
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @return [Hash] defined attributes
|
|
74
|
+
def attributes
|
|
75
|
+
@attributes ||= {}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Define a validation
|
|
79
|
+
#
|
|
80
|
+
# @param attribute [Symbol] attribute to validate
|
|
81
|
+
# @param options [Hash] validation options
|
|
82
|
+
#
|
|
83
|
+
# @example
|
|
84
|
+
# validates :email, presence: true, format: /@/
|
|
85
|
+
# validates :age, numericality: true, greater_than: 0
|
|
86
|
+
def validates(attribute, **options)
|
|
87
|
+
validations[attribute] ||= []
|
|
88
|
+
validations[attribute] << options
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# @return [Hash] defined validations
|
|
92
|
+
def validations
|
|
93
|
+
@validations ||= {}
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Define a has_many association
|
|
97
|
+
#
|
|
98
|
+
# @param name [Symbol] association name
|
|
99
|
+
# @param options [Hash] association options
|
|
100
|
+
#
|
|
101
|
+
# @example
|
|
102
|
+
# has_many :posts
|
|
103
|
+
# has_many :comments, class_name: 'Comment', foreign_key: :author_id
|
|
104
|
+
def has_many(name, class_name: nil, foreign_key: nil, **options)
|
|
105
|
+
associations[:has_many] ||= {}
|
|
106
|
+
associations[:has_many][name] = {
|
|
107
|
+
class_name: class_name || classify(name),
|
|
108
|
+
foreign_key: foreign_key || "#{table.chomp('s')}_id",
|
|
109
|
+
**options
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
define_method(name) do
|
|
113
|
+
@cache[name] ||= load_has_many(name)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Define a belongs_to association
|
|
118
|
+
#
|
|
119
|
+
# @param name [Symbol] association name
|
|
120
|
+
# @param options [Hash] association options
|
|
121
|
+
#
|
|
122
|
+
# @example
|
|
123
|
+
# belongs_to :user
|
|
124
|
+
# belongs_to :author, class_name: 'User'
|
|
125
|
+
def belongs_to(name, class_name: nil, foreign_key: nil, **options)
|
|
126
|
+
associations[:belongs_to] ||= {}
|
|
127
|
+
associations[:belongs_to][name] = {
|
|
128
|
+
class_name: class_name || classify(name),
|
|
129
|
+
foreign_key: foreign_key || "#{name}_id",
|
|
130
|
+
**options
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
define_method(name) do
|
|
134
|
+
@cache[name] ||= load_belongs_to(name)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Define a has_one association
|
|
139
|
+
#
|
|
140
|
+
# @param name [Symbol] association name
|
|
141
|
+
# @param options [Hash] association options
|
|
142
|
+
def has_one(name, class_name: nil, foreign_key: nil, **options)
|
|
143
|
+
associations[:has_one] ||= {}
|
|
144
|
+
associations[:has_one][name] = {
|
|
145
|
+
class_name: class_name || classify(name),
|
|
146
|
+
foreign_key: foreign_key || "#{table.chomp('s')}_id",
|
|
147
|
+
**options
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
define_method(name) do
|
|
151
|
+
@cache[name] ||= load_has_one(name)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# @return [Hash] defined associations
|
|
156
|
+
def associations
|
|
157
|
+
@associations ||= {}
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Find a record by ID
|
|
161
|
+
#
|
|
162
|
+
# @param id [Object] primary key value
|
|
163
|
+
# @return [Model, nil]
|
|
164
|
+
def find(id)
|
|
165
|
+
row = db.get("SELECT * FROM #{table} WHERE #{primary_key} = ?", id)
|
|
166
|
+
row ? from_row(row) : nil
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Find a record by ID or raise
|
|
170
|
+
#
|
|
171
|
+
# @param id [Object] primary key value
|
|
172
|
+
# @return [Model]
|
|
173
|
+
# @raise [RecordNotFoundError]
|
|
174
|
+
def find!(id)
|
|
175
|
+
find(id) || raise(RecordNotFoundError.new(self, id))
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Find by attributes
|
|
179
|
+
#
|
|
180
|
+
# @param attributes [Hash] attributes to match
|
|
181
|
+
# @return [Model, nil]
|
|
182
|
+
def find_by(**attributes)
|
|
183
|
+
where(attributes).first
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Get all records
|
|
187
|
+
#
|
|
188
|
+
# @return [Array<Model>]
|
|
189
|
+
def all
|
|
190
|
+
query.to_a
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Create a new query
|
|
194
|
+
#
|
|
195
|
+
# @return [Query]
|
|
196
|
+
def query
|
|
197
|
+
Query.new(self)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Query methods delegation
|
|
201
|
+
%i[where where_not order limit offset select].each do |method|
|
|
202
|
+
define_method(method) { |*args, **kwargs| query.send(method, *args, **kwargs) }
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Create a new record
|
|
206
|
+
#
|
|
207
|
+
# @param attributes [Hash] record attributes
|
|
208
|
+
# @return [Model]
|
|
209
|
+
def create(attributes = {})
|
|
210
|
+
record = new(attributes)
|
|
211
|
+
record.save
|
|
212
|
+
record
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Create a new record or raise on failure
|
|
216
|
+
#
|
|
217
|
+
# @param attributes [Hash] record attributes
|
|
218
|
+
# @return [Model]
|
|
219
|
+
# @raise [ValidationError]
|
|
220
|
+
def create!(attributes = {})
|
|
221
|
+
record = new(attributes)
|
|
222
|
+
record.save!
|
|
223
|
+
record
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Count records
|
|
227
|
+
#
|
|
228
|
+
# @return [Integer]
|
|
229
|
+
def count
|
|
230
|
+
result = db.get("SELECT COUNT(*) as count FROM #{table}")
|
|
231
|
+
result[:count]
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Check if any records exist
|
|
235
|
+
#
|
|
236
|
+
# @return [Boolean]
|
|
237
|
+
def exists?(**conditions)
|
|
238
|
+
conditions.empty? ? count > 0 : where(conditions).exists?
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Coerce a value to the specified type
|
|
242
|
+
#
|
|
243
|
+
# @param value [Object] value to coerce
|
|
244
|
+
# @param type [Symbol] target type
|
|
245
|
+
# @return [Object]
|
|
246
|
+
def coerce(value, type)
|
|
247
|
+
return nil if value.nil?
|
|
248
|
+
|
|
249
|
+
case type
|
|
250
|
+
when :string then value.to_s
|
|
251
|
+
when :integer then value.to_i
|
|
252
|
+
when :float then value.to_f
|
|
253
|
+
when :boolean then coerce_boolean(value)
|
|
254
|
+
when :datetime then coerce_datetime(value)
|
|
255
|
+
when :date then coerce_date(value)
|
|
256
|
+
when :json then coerce_json(value)
|
|
257
|
+
else value
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Database connection
|
|
262
|
+
#
|
|
263
|
+
# @return [Database::Connection]
|
|
264
|
+
def db
|
|
265
|
+
Database.connection || raise(DatabaseError, "No database connection")
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Build model from database row
|
|
269
|
+
#
|
|
270
|
+
# @param row [Hash] database row
|
|
271
|
+
# @return [Model]
|
|
272
|
+
def from_row(row)
|
|
273
|
+
record = allocate
|
|
274
|
+
record.instance_variable_set(:@attributes, row)
|
|
275
|
+
record.instance_variable_set(:@new_record, false)
|
|
276
|
+
record.instance_variable_set(:@destroyed, false)
|
|
277
|
+
record.instance_variable_set(:@dirty, [])
|
|
278
|
+
record.instance_variable_set(:@cache, {})
|
|
279
|
+
record.instance_variable_set(:@errors, {})
|
|
280
|
+
record
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
private
|
|
284
|
+
|
|
285
|
+
def classify(name)
|
|
286
|
+
name.to_s.split("_").map(&:capitalize).join.chomp("s")
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def coerce_boolean(value)
|
|
290
|
+
case value
|
|
291
|
+
when true, 1, "1", "true", "t" then true
|
|
292
|
+
when false, 0, "0", "false", "f" then false
|
|
293
|
+
else !!value
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def coerce_datetime(value)
|
|
298
|
+
return value if value.is_a?(Time)
|
|
299
|
+
|
|
300
|
+
Time.parse(value.to_s)
|
|
301
|
+
rescue ArgumentError
|
|
302
|
+
nil
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def coerce_date(value)
|
|
306
|
+
return value if value.is_a?(Date)
|
|
307
|
+
|
|
308
|
+
Date.parse(value.to_s)
|
|
309
|
+
rescue ArgumentError
|
|
310
|
+
nil
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def coerce_json(value)
|
|
314
|
+
return value if value.is_a?(Hash) || value.is_a?(Array)
|
|
315
|
+
|
|
316
|
+
JSON.parse(value.to_s)
|
|
317
|
+
rescue JSON::ParserError
|
|
318
|
+
nil
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# @return [Hash] validation errors
|
|
323
|
+
attr_reader :errors
|
|
324
|
+
|
|
325
|
+
# Create a new model instance
|
|
326
|
+
#
|
|
327
|
+
# @param attributes [Hash] initial attributes
|
|
328
|
+
def initialize(attributes = {})
|
|
329
|
+
@attributes = {}
|
|
330
|
+
@dirty = []
|
|
331
|
+
@cache = {}
|
|
332
|
+
@errors = {}
|
|
333
|
+
@new_record = true
|
|
334
|
+
@destroyed = false
|
|
335
|
+
|
|
336
|
+
# Set defaults
|
|
337
|
+
self.class.attributes.each do |name, config|
|
|
338
|
+
@attributes[name] = config[:default]
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Set provided attributes
|
|
342
|
+
attributes.each do |key, value|
|
|
343
|
+
send("#{key}=", value) if respond_to?("#{key}=")
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# Get the primary key value
|
|
348
|
+
#
|
|
349
|
+
# @return [Object]
|
|
350
|
+
def id
|
|
351
|
+
@attributes[self.class.primary_key]
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# Set the primary key value
|
|
355
|
+
#
|
|
356
|
+
# @param value [Object]
|
|
357
|
+
def id=(value)
|
|
358
|
+
@attributes[self.class.primary_key] = value
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# @return [Boolean] true if this is a new record
|
|
362
|
+
def new_record?
|
|
363
|
+
@new_record
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# @return [Boolean] true if record has been persisted
|
|
367
|
+
def persisted?
|
|
368
|
+
!@new_record && !@destroyed
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# @return [Boolean] true if record has been destroyed
|
|
372
|
+
def destroyed?
|
|
373
|
+
@destroyed || false
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# @return [Boolean] true if record has unsaved changes
|
|
377
|
+
def changed?
|
|
378
|
+
@dirty.any?
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# Validate the record
|
|
382
|
+
#
|
|
383
|
+
# @return [Boolean]
|
|
384
|
+
def valid?
|
|
385
|
+
@errors.clear
|
|
386
|
+
run_validations
|
|
387
|
+
@errors.empty?
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# Save the record
|
|
391
|
+
#
|
|
392
|
+
# @return [Boolean]
|
|
393
|
+
def save
|
|
394
|
+
return false unless valid?
|
|
395
|
+
|
|
396
|
+
if @new_record
|
|
397
|
+
insert_record
|
|
398
|
+
else
|
|
399
|
+
update_record
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
@dirty.clear
|
|
403
|
+
true
|
|
404
|
+
rescue StandardError => e
|
|
405
|
+
@errors[:base] = [e.message]
|
|
406
|
+
false
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# Save the record or raise on failure
|
|
410
|
+
#
|
|
411
|
+
# @return [Boolean]
|
|
412
|
+
# @raise [ValidationError]
|
|
413
|
+
def save!
|
|
414
|
+
raise ValidationError, @errors unless save
|
|
415
|
+
|
|
416
|
+
true
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# Update attributes
|
|
420
|
+
#
|
|
421
|
+
# @param attributes [Hash] attributes to update
|
|
422
|
+
# @return [Boolean]
|
|
423
|
+
def update(attributes)
|
|
424
|
+
attributes.each { |k, v| send("#{k}=", v) if respond_to?("#{k}=") }
|
|
425
|
+
save
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# Update attributes or raise on failure
|
|
429
|
+
#
|
|
430
|
+
# @param attributes [Hash] attributes to update
|
|
431
|
+
# @return [Boolean]
|
|
432
|
+
# @raise [ValidationError]
|
|
433
|
+
def update!(attributes)
|
|
434
|
+
attributes.each { |k, v| send("#{k}=", v) if respond_to?("#{k}=") }
|
|
435
|
+
save!
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# Destroy the record
|
|
439
|
+
#
|
|
440
|
+
# @return [Boolean]
|
|
441
|
+
def destroy
|
|
442
|
+
return false if @new_record
|
|
443
|
+
|
|
444
|
+
self.class.db.delete(self.class.table, self.class.primary_key => id)
|
|
445
|
+
@destroyed = true
|
|
446
|
+
true
|
|
447
|
+
rescue StandardError
|
|
448
|
+
false
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# Reload the record from database
|
|
452
|
+
#
|
|
453
|
+
# @return [self]
|
|
454
|
+
def reload
|
|
455
|
+
fresh = self.class.find(id)
|
|
456
|
+
@attributes = fresh.to_h
|
|
457
|
+
@dirty.clear
|
|
458
|
+
@cache.clear
|
|
459
|
+
self
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
# Convert to hash
|
|
463
|
+
#
|
|
464
|
+
# @return [Hash]
|
|
465
|
+
def to_h
|
|
466
|
+
@attributes.dup
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
alias to_hash to_h
|
|
470
|
+
|
|
471
|
+
# Convert to JSON
|
|
472
|
+
#
|
|
473
|
+
# @return [String]
|
|
474
|
+
def to_json(*args)
|
|
475
|
+
to_h.to_json(*args)
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
# String representation
|
|
479
|
+
#
|
|
480
|
+
# @return [String]
|
|
481
|
+
def inspect
|
|
482
|
+
attrs = @attributes.map { |k, v| "#{k}: #{v.inspect}" }.join(", ")
|
|
483
|
+
"#<#{self.class.name} #{attrs}>"
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
private
|
|
487
|
+
|
|
488
|
+
def run_validations
|
|
489
|
+
self.class.validations.each do |attribute, rules|
|
|
490
|
+
value = @attributes[attribute]
|
|
491
|
+
rules.each { |rule| validate_rule(attribute, value, rule) }
|
|
492
|
+
end
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def validate_rule(attribute, value, rule)
|
|
496
|
+
if rule[:presence] && (value.nil? || value == "")
|
|
497
|
+
add_error(attribute, "can't be blank")
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
if rule[:format] && value && !value.to_s.match?(rule[:format])
|
|
501
|
+
add_error(attribute, "has invalid format")
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
if rule[:length]
|
|
505
|
+
length = value.to_s.length
|
|
506
|
+
min = rule[:length][:minimum]
|
|
507
|
+
max = rule[:length][:maximum]
|
|
508
|
+
add_error(attribute, "is too short (minimum #{min})") if min && length < min
|
|
509
|
+
add_error(attribute, "is too long (maximum #{max})") if max && length > max
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
if rule[:inclusion] && !rule[:inclusion][:in].include?(value)
|
|
513
|
+
add_error(attribute, "is not included in the list")
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
if rule[:numericality] && value && !value.is_a?(Numeric)
|
|
517
|
+
add_error(attribute, "is not a number")
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
if rule[:greater_than] && value.is_a?(Numeric) && value <= rule[:greater_than]
|
|
521
|
+
add_error(attribute, "must be greater than #{rule[:greater_than]}")
|
|
522
|
+
end
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
def add_error(attribute, message)
|
|
526
|
+
@errors[attribute] ||= []
|
|
527
|
+
@errors[attribute] << message
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
def insert_record
|
|
531
|
+
attrs = @attributes.reject { |k, _| k == self.class.primary_key }
|
|
532
|
+
new_id = self.class.db.insert(self.class.table, attrs)
|
|
533
|
+
@attributes[self.class.primary_key] = new_id
|
|
534
|
+
@new_record = false
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
def update_record
|
|
538
|
+
attrs = @dirty.each_with_object({}) { |k, h| h[k] = @attributes[k] }
|
|
539
|
+
return if attrs.empty?
|
|
540
|
+
|
|
541
|
+
self.class.db.update(self.class.table, attrs, self.class.primary_key => id)
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
def load_has_many(name)
|
|
545
|
+
config = self.class.associations[:has_many][name]
|
|
546
|
+
klass = Object.const_get(config[:class_name])
|
|
547
|
+
klass.where(config[:foreign_key] => id).to_a
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
def load_belongs_to(name)
|
|
551
|
+
config = self.class.associations[:belongs_to][name]
|
|
552
|
+
klass = Object.const_get(config[:class_name])
|
|
553
|
+
fk_value = @attributes[config[:foreign_key].to_sym]
|
|
554
|
+
klass.find(fk_value)
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
def load_has_one(name)
|
|
558
|
+
config = self.class.associations[:has_one][name]
|
|
559
|
+
klass = Object.const_get(config[:class_name])
|
|
560
|
+
klass.find_by(config[:foreign_key] => id)
|
|
561
|
+
end
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
# Record not found error
|
|
565
|
+
class RecordNotFoundError < Error
|
|
566
|
+
# @return [Class] the model class
|
|
567
|
+
attr_reader :model
|
|
568
|
+
|
|
569
|
+
# @return [Object] the ID that was not found
|
|
570
|
+
attr_reader :id
|
|
571
|
+
|
|
572
|
+
def initialize(model, id)
|
|
573
|
+
@model = model
|
|
574
|
+
@id = id
|
|
575
|
+
super("#{model.name} with #{model.primary_key}=#{id} not found")
|
|
576
|
+
end
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
# Validation error
|
|
580
|
+
class ValidationError < Error
|
|
581
|
+
# @return [Hash] validation errors
|
|
582
|
+
attr_reader :errors
|
|
583
|
+
|
|
584
|
+
def initialize(errors)
|
|
585
|
+
@errors = errors
|
|
586
|
+
messages = errors.flat_map { |k, v| v.map { |m| "#{k} #{m}" } }
|
|
587
|
+
super("Validation failed: #{messages.join(', ')}")
|
|
588
|
+
end
|
|
589
|
+
end
|
|
590
|
+
end
|