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.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +175 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +984 -0
  5. data/exe/tarsier +7 -0
  6. data/lib/tarsier/application.rb +336 -0
  7. data/lib/tarsier/cli/commands/console.rb +87 -0
  8. data/lib/tarsier/cli/commands/generate.rb +85 -0
  9. data/lib/tarsier/cli/commands/help.rb +50 -0
  10. data/lib/tarsier/cli/commands/new.rb +59 -0
  11. data/lib/tarsier/cli/commands/routes.rb +139 -0
  12. data/lib/tarsier/cli/commands/server.rb +123 -0
  13. data/lib/tarsier/cli/commands/version.rb +14 -0
  14. data/lib/tarsier/cli/generators/app.rb +528 -0
  15. data/lib/tarsier/cli/generators/base.rb +93 -0
  16. data/lib/tarsier/cli/generators/controller.rb +91 -0
  17. data/lib/tarsier/cli/generators/middleware.rb +81 -0
  18. data/lib/tarsier/cli/generators/migration.rb +109 -0
  19. data/lib/tarsier/cli/generators/model.rb +109 -0
  20. data/lib/tarsier/cli/generators/resource.rb +27 -0
  21. data/lib/tarsier/cli/loader.rb +18 -0
  22. data/lib/tarsier/cli.rb +46 -0
  23. data/lib/tarsier/controller.rb +282 -0
  24. data/lib/tarsier/database.rb +588 -0
  25. data/lib/tarsier/errors.rb +77 -0
  26. data/lib/tarsier/middleware/base.rb +47 -0
  27. data/lib/tarsier/middleware/compression.rb +113 -0
  28. data/lib/tarsier/middleware/cors.rb +101 -0
  29. data/lib/tarsier/middleware/csrf.rb +88 -0
  30. data/lib/tarsier/middleware/logger.rb +74 -0
  31. data/lib/tarsier/middleware/rate_limit.rb +110 -0
  32. data/lib/tarsier/middleware/stack.rb +143 -0
  33. data/lib/tarsier/middleware/static.rb +124 -0
  34. data/lib/tarsier/model.rb +590 -0
  35. data/lib/tarsier/params.rb +269 -0
  36. data/lib/tarsier/query.rb +495 -0
  37. data/lib/tarsier/request.rb +274 -0
  38. data/lib/tarsier/response.rb +282 -0
  39. data/lib/tarsier/router/compiler.rb +173 -0
  40. data/lib/tarsier/router/node.rb +97 -0
  41. data/lib/tarsier/router/route.rb +119 -0
  42. data/lib/tarsier/router.rb +272 -0
  43. data/lib/tarsier/version.rb +5 -0
  44. data/lib/tarsier/websocket.rb +275 -0
  45. data/lib/tarsier.rb +167 -0
  46. data/sig/tarsier.rbs +485 -0
  47. 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