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,588 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarsier
|
|
4
|
+
# Database abstraction layer with support for SQLite, PostgreSQL, and MySQL
|
|
5
|
+
#
|
|
6
|
+
# Provides a unified interface for database operations across different
|
|
7
|
+
# adapters. Connections are lazy-loaded and pooled for performance.
|
|
8
|
+
#
|
|
9
|
+
# @example Connect to SQLite
|
|
10
|
+
# Tarsier.db :sqlite, 'app.db'
|
|
11
|
+
#
|
|
12
|
+
# @example Connect to PostgreSQL
|
|
13
|
+
# Tarsier.db :postgres, 'postgres://localhost/myapp'
|
|
14
|
+
#
|
|
15
|
+
# @example Raw queries
|
|
16
|
+
# Tarsier.database.execute('SELECT * FROM users WHERE id = ?', 1)
|
|
17
|
+
#
|
|
18
|
+
# @since 0.1.0
|
|
19
|
+
module Database
|
|
20
|
+
# Supported database adapters
|
|
21
|
+
ADAPTERS = {
|
|
22
|
+
sqlite: "SQLite",
|
|
23
|
+
sqlite3: "SQLite",
|
|
24
|
+
postgres: "PostgreSQL",
|
|
25
|
+
postgresql: "PostgreSQL",
|
|
26
|
+
pg: "PostgreSQL",
|
|
27
|
+
mysql: "MySQL",
|
|
28
|
+
mysql2: "MySQL"
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
# @return [Connection, nil] the current database connection
|
|
33
|
+
attr_reader :connection
|
|
34
|
+
|
|
35
|
+
# Establish a database connection
|
|
36
|
+
#
|
|
37
|
+
# @param adapter [Symbol] database adapter
|
|
38
|
+
# @param connection_string [String, nil] connection URL or path
|
|
39
|
+
# @param options [Hash] connection options
|
|
40
|
+
# @return [Connection]
|
|
41
|
+
# @raise [DatabaseError] if adapter is unsupported
|
|
42
|
+
#
|
|
43
|
+
# @example
|
|
44
|
+
# Database.connect(:sqlite, 'db/app.db')
|
|
45
|
+
# Database.connect(:postgres, host: 'localhost', database: 'myapp')
|
|
46
|
+
def connect(adapter, connection_string = nil, **options)
|
|
47
|
+
adapter_name = ADAPTERS[adapter.to_sym]
|
|
48
|
+
raise DatabaseError, "Unsupported adapter: #{adapter}" unless adapter_name
|
|
49
|
+
|
|
50
|
+
@connection = Connection.new(adapter, connection_string, **options)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Disconnect from database
|
|
54
|
+
#
|
|
55
|
+
# @return [void]
|
|
56
|
+
def disconnect
|
|
57
|
+
@connection&.close
|
|
58
|
+
@connection = nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Check if connected
|
|
62
|
+
#
|
|
63
|
+
# @return [Boolean]
|
|
64
|
+
def connected?
|
|
65
|
+
@connection&.connected? || false
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Database connection wrapper
|
|
70
|
+
#
|
|
71
|
+
# Provides a unified interface for executing queries across different
|
|
72
|
+
# database adapters. Handles connection pooling and query execution.
|
|
73
|
+
#
|
|
74
|
+
# @since 0.1.0
|
|
75
|
+
class Connection
|
|
76
|
+
# @return [Symbol] the database adapter
|
|
77
|
+
attr_reader :adapter
|
|
78
|
+
|
|
79
|
+
# @return [Object] the underlying database connection
|
|
80
|
+
attr_reader :raw_connection
|
|
81
|
+
|
|
82
|
+
# Create a new database connection
|
|
83
|
+
#
|
|
84
|
+
# @param adapter [Symbol] database adapter
|
|
85
|
+
# @param connection_string [String, nil] connection URL or path
|
|
86
|
+
# @param options [Hash] connection options
|
|
87
|
+
def initialize(adapter, connection_string = nil, **options)
|
|
88
|
+
@adapter = normalize_adapter(adapter)
|
|
89
|
+
@options = parse_connection(connection_string, options)
|
|
90
|
+
@raw_connection = nil
|
|
91
|
+
@connected = false
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Execute a SQL query
|
|
95
|
+
#
|
|
96
|
+
# @param sql [String] SQL query
|
|
97
|
+
# @param params [Array] query parameters
|
|
98
|
+
# @return [Array<Hash>] query results
|
|
99
|
+
#
|
|
100
|
+
# @example
|
|
101
|
+
# connection.execute('SELECT * FROM users WHERE id = ?', 1)
|
|
102
|
+
def execute(sql, *params)
|
|
103
|
+
ensure_connected
|
|
104
|
+
execute_query(sql, params)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Execute a query and return first result
|
|
108
|
+
#
|
|
109
|
+
# @param sql [String] SQL query
|
|
110
|
+
# @param params [Array] query parameters
|
|
111
|
+
# @return [Hash, nil]
|
|
112
|
+
def get(sql, *params)
|
|
113
|
+
execute(sql, *params).first
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Insert a record and return the ID
|
|
117
|
+
#
|
|
118
|
+
# @param table [String, Symbol] table name
|
|
119
|
+
# @param attributes [Hash] column values
|
|
120
|
+
# @return [Integer] inserted row ID
|
|
121
|
+
def insert(table, attributes)
|
|
122
|
+
columns = attributes.keys.join(", ")
|
|
123
|
+
placeholders = (["?"] * attributes.size).join(", ")
|
|
124
|
+
sql = "INSERT INTO #{table} (#{columns}) VALUES (#{placeholders})"
|
|
125
|
+
|
|
126
|
+
execute(sql, *attributes.values)
|
|
127
|
+
last_insert_id
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Update records
|
|
131
|
+
#
|
|
132
|
+
# @param table [String, Symbol] table name
|
|
133
|
+
# @param attributes [Hash] column values to update
|
|
134
|
+
# @param conditions [Hash] WHERE conditions
|
|
135
|
+
# @return [Integer] number of affected rows
|
|
136
|
+
def update(table, attributes, conditions = {})
|
|
137
|
+
set_clause = attributes.keys.map { |k| "#{k} = ?" }.join(", ")
|
|
138
|
+
where_clause, where_values = build_where(conditions)
|
|
139
|
+
|
|
140
|
+
sql = "UPDATE #{table} SET #{set_clause}"
|
|
141
|
+
sql += " WHERE #{where_clause}" unless where_clause.empty?
|
|
142
|
+
|
|
143
|
+
execute(sql, *attributes.values, *where_values)
|
|
144
|
+
affected_rows
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Delete records
|
|
148
|
+
#
|
|
149
|
+
# @param table [String, Symbol] table name
|
|
150
|
+
# @param conditions [Hash] WHERE conditions
|
|
151
|
+
# @return [Integer] number of affected rows
|
|
152
|
+
def delete(table, conditions = {})
|
|
153
|
+
where_clause, where_values = build_where(conditions)
|
|
154
|
+
|
|
155
|
+
sql = "DELETE FROM #{table}"
|
|
156
|
+
sql += " WHERE #{where_clause}" unless where_clause.empty?
|
|
157
|
+
|
|
158
|
+
execute(sql, *where_values)
|
|
159
|
+
affected_rows
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Check if connected
|
|
163
|
+
#
|
|
164
|
+
# @return [Boolean]
|
|
165
|
+
def connected?
|
|
166
|
+
@connected && !@raw_connection.nil?
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Close the connection
|
|
170
|
+
#
|
|
171
|
+
# @return [void]
|
|
172
|
+
def close
|
|
173
|
+
return unless @raw_connection
|
|
174
|
+
|
|
175
|
+
case @adapter
|
|
176
|
+
when :sqlite
|
|
177
|
+
@raw_connection.close
|
|
178
|
+
when :postgres
|
|
179
|
+
@raw_connection.close
|
|
180
|
+
when :mysql
|
|
181
|
+
@raw_connection.close
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
@raw_connection = nil
|
|
185
|
+
@connected = false
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Run migrations
|
|
189
|
+
#
|
|
190
|
+
# @param direction [Symbol] :up or :down
|
|
191
|
+
# @return [void]
|
|
192
|
+
def migrate(direction = :up)
|
|
193
|
+
migrations_path = File.join(Tarsier.root, "db", "migrations")
|
|
194
|
+
return unless File.directory?(migrations_path)
|
|
195
|
+
|
|
196
|
+
create_migrations_table
|
|
197
|
+
|
|
198
|
+
Dir.glob(File.join(migrations_path, "*.rb")).sort.each do |file|
|
|
199
|
+
version = File.basename(file).split("_").first
|
|
200
|
+
next if direction == :up && migrated?(version)
|
|
201
|
+
next if direction == :down && !migrated?(version)
|
|
202
|
+
|
|
203
|
+
require file
|
|
204
|
+
class_name = File.basename(file, ".rb").split("_")[1..].map(&:capitalize).join
|
|
205
|
+
migration = Object.const_get(class_name).new(self)
|
|
206
|
+
|
|
207
|
+
if direction == :up
|
|
208
|
+
migration.up
|
|
209
|
+
record_migration(version)
|
|
210
|
+
else
|
|
211
|
+
migration.down
|
|
212
|
+
remove_migration(version)
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Begin a transaction
|
|
218
|
+
#
|
|
219
|
+
# @yield block to execute within transaction
|
|
220
|
+
# @return [Object] block result
|
|
221
|
+
def transaction
|
|
222
|
+
execute("BEGIN")
|
|
223
|
+
result = yield
|
|
224
|
+
execute("COMMIT")
|
|
225
|
+
result
|
|
226
|
+
rescue StandardError => e
|
|
227
|
+
execute("ROLLBACK")
|
|
228
|
+
raise e
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
private
|
|
232
|
+
|
|
233
|
+
def normalize_adapter(adapter)
|
|
234
|
+
case adapter.to_sym
|
|
235
|
+
when :sqlite, :sqlite3 then :sqlite
|
|
236
|
+
when :postgres, :postgresql, :pg then :postgres
|
|
237
|
+
when :mysql, :mysql2 then :mysql
|
|
238
|
+
else adapter.to_sym
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def parse_connection(connection_string, options)
|
|
243
|
+
return options unless connection_string
|
|
244
|
+
|
|
245
|
+
if connection_string.is_a?(String) && connection_string.include?("://")
|
|
246
|
+
parse_url(connection_string)
|
|
247
|
+
else
|
|
248
|
+
options.merge(database: connection_string)
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def parse_url(url)
|
|
253
|
+
uri = URI.parse(url)
|
|
254
|
+
{
|
|
255
|
+
host: uri.host,
|
|
256
|
+
port: uri.port,
|
|
257
|
+
database: uri.path[1..],
|
|
258
|
+
username: uri.user,
|
|
259
|
+
password: uri.password
|
|
260
|
+
}.compact
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def ensure_connected
|
|
264
|
+
return if connected?
|
|
265
|
+
|
|
266
|
+
@raw_connection = create_connection
|
|
267
|
+
@connected = true
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def create_connection
|
|
271
|
+
case @adapter
|
|
272
|
+
when :sqlite
|
|
273
|
+
require "sqlite3"
|
|
274
|
+
SQLite3::Database.new(@options[:database] || ":memory:")
|
|
275
|
+
when :postgres
|
|
276
|
+
require "pg"
|
|
277
|
+
PG.connect(**pg_options)
|
|
278
|
+
when :mysql
|
|
279
|
+
require "mysql2"
|
|
280
|
+
Mysql2::Client.new(**mysql_options)
|
|
281
|
+
else
|
|
282
|
+
raise DatabaseError, "Unsupported adapter: #{@adapter}"
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def pg_options
|
|
287
|
+
{
|
|
288
|
+
host: @options[:host] || "localhost",
|
|
289
|
+
port: @options[:port] || 5432,
|
|
290
|
+
dbname: @options[:database],
|
|
291
|
+
user: @options[:username],
|
|
292
|
+
password: @options[:password]
|
|
293
|
+
}.compact
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def mysql_options
|
|
297
|
+
{
|
|
298
|
+
host: @options[:host] || "localhost",
|
|
299
|
+
port: @options[:port] || 3306,
|
|
300
|
+
database: @options[:database],
|
|
301
|
+
username: @options[:username],
|
|
302
|
+
password: @options[:password]
|
|
303
|
+
}.compact
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def execute_query(sql, params)
|
|
307
|
+
case @adapter
|
|
308
|
+
when :sqlite
|
|
309
|
+
execute_sqlite(sql, params)
|
|
310
|
+
when :postgres
|
|
311
|
+
execute_postgres(sql, params)
|
|
312
|
+
when :mysql
|
|
313
|
+
execute_mysql(sql, params)
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def execute_sqlite(sql, params)
|
|
318
|
+
@raw_connection.results_as_hash = true
|
|
319
|
+
stmt = @raw_connection.prepare(sql)
|
|
320
|
+
result = stmt.execute(*params)
|
|
321
|
+
result.to_a.map { |row| symbolize_keys(row) }
|
|
322
|
+
ensure
|
|
323
|
+
stmt&.close
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def execute_postgres(sql, params)
|
|
327
|
+
# Convert ? placeholders to $1, $2, etc.
|
|
328
|
+
pg_sql = sql.gsub(/\?/).with_index(1) { |_, i| "$#{i}" }
|
|
329
|
+
result = @raw_connection.exec_params(pg_sql, params)
|
|
330
|
+
result.map { |row| symbolize_keys(row) }
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def execute_mysql(sql, params)
|
|
334
|
+
stmt = @raw_connection.prepare(sql)
|
|
335
|
+
result = stmt.execute(*params)
|
|
336
|
+
result&.to_a&.map { |row| symbolize_keys(row) } || []
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def symbolize_keys(hash)
|
|
340
|
+
hash.transform_keys { |k| k.is_a?(String) ? k.to_sym : k }
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def last_insert_id
|
|
344
|
+
case @adapter
|
|
345
|
+
when :sqlite
|
|
346
|
+
@raw_connection.last_insert_row_id
|
|
347
|
+
when :postgres
|
|
348
|
+
execute("SELECT lastval()").first&.values&.first
|
|
349
|
+
when :mysql
|
|
350
|
+
@raw_connection.last_id
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def affected_rows
|
|
355
|
+
case @adapter
|
|
356
|
+
when :sqlite
|
|
357
|
+
@raw_connection.changes
|
|
358
|
+
when :postgres
|
|
359
|
+
@raw_connection.cmd_tuples
|
|
360
|
+
when :mysql
|
|
361
|
+
@raw_connection.affected_rows
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def build_where(conditions)
|
|
366
|
+
return ["", []] if conditions.empty?
|
|
367
|
+
|
|
368
|
+
clauses = conditions.map { |k, _| "#{k} = ?" }
|
|
369
|
+
[clauses.join(" AND "), conditions.values]
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def create_migrations_table
|
|
373
|
+
sql = <<~SQL
|
|
374
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
375
|
+
version VARCHAR(255) PRIMARY KEY
|
|
376
|
+
)
|
|
377
|
+
SQL
|
|
378
|
+
execute(sql)
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def migrated?(version)
|
|
382
|
+
result = execute("SELECT version FROM schema_migrations WHERE version = ?", version)
|
|
383
|
+
result.any?
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def record_migration(version)
|
|
387
|
+
execute("INSERT INTO schema_migrations (version) VALUES (?)", version)
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def remove_migration(version)
|
|
391
|
+
execute("DELETE FROM schema_migrations WHERE version = ?", version)
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# Base class for migrations
|
|
396
|
+
#
|
|
397
|
+
# @example
|
|
398
|
+
# class CreateUsers < Tarsier::Database::Migration
|
|
399
|
+
# def up
|
|
400
|
+
# create_table :users do |t|
|
|
401
|
+
# t.string :name
|
|
402
|
+
# t.string :email
|
|
403
|
+
# t.timestamps
|
|
404
|
+
# end
|
|
405
|
+
# end
|
|
406
|
+
#
|
|
407
|
+
# def down
|
|
408
|
+
# drop_table :users
|
|
409
|
+
# end
|
|
410
|
+
# end
|
|
411
|
+
#
|
|
412
|
+
# @since 0.1.0
|
|
413
|
+
class Migration
|
|
414
|
+
# @param connection [Connection] database connection
|
|
415
|
+
def initialize(connection)
|
|
416
|
+
@connection = connection
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# Override in subclass
|
|
420
|
+
def up
|
|
421
|
+
raise NotImplementedError
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# Override in subclass
|
|
425
|
+
def down
|
|
426
|
+
raise NotImplementedError
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# Create a table
|
|
430
|
+
#
|
|
431
|
+
# @param name [Symbol, String] table name
|
|
432
|
+
# @yield [TableDefinition] table definition
|
|
433
|
+
def create_table(name, &block)
|
|
434
|
+
definition = TableDefinition.new(name)
|
|
435
|
+
definition.instance_eval(&block) if block_given?
|
|
436
|
+
@connection.execute(definition.to_sql)
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# Drop a table
|
|
440
|
+
#
|
|
441
|
+
# @param name [Symbol, String] table name
|
|
442
|
+
def drop_table(name)
|
|
443
|
+
@connection.execute("DROP TABLE IF EXISTS #{name}")
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# Add a column
|
|
447
|
+
#
|
|
448
|
+
# @param table [Symbol, String] table name
|
|
449
|
+
# @param column [Symbol, String] column name
|
|
450
|
+
# @param type [Symbol] column type
|
|
451
|
+
# @param options [Hash] column options
|
|
452
|
+
def add_column(table, column, type, **options)
|
|
453
|
+
sql_type = TableDefinition.sql_type(type, options)
|
|
454
|
+
@connection.execute("ALTER TABLE #{table} ADD COLUMN #{column} #{sql_type}")
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
# Remove a column
|
|
458
|
+
#
|
|
459
|
+
# @param table [Symbol, String] table name
|
|
460
|
+
# @param column [Symbol, String] column name
|
|
461
|
+
def remove_column(table, column)
|
|
462
|
+
@connection.execute("ALTER TABLE #{table} DROP COLUMN #{column}")
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
# Add an index
|
|
466
|
+
#
|
|
467
|
+
# @param table [Symbol, String] table name
|
|
468
|
+
# @param columns [Array<Symbol>] column names
|
|
469
|
+
# @param options [Hash] index options
|
|
470
|
+
def add_index(table, columns, unique: false, name: nil)
|
|
471
|
+
columns = Array(columns)
|
|
472
|
+
index_name = name || "idx_#{table}_#{columns.join('_')}"
|
|
473
|
+
unique_str = unique ? "UNIQUE " : ""
|
|
474
|
+
@connection.execute("CREATE #{unique_str}INDEX #{index_name} ON #{table} (#{columns.join(', ')})")
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
# Execute raw SQL
|
|
478
|
+
#
|
|
479
|
+
# @param sql [String] SQL statement
|
|
480
|
+
def execute(sql)
|
|
481
|
+
@connection.execute(sql)
|
|
482
|
+
end
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# Table definition builder for migrations
|
|
486
|
+
#
|
|
487
|
+
# @since 0.1.0
|
|
488
|
+
class TableDefinition
|
|
489
|
+
TYPE_MAP = {
|
|
490
|
+
string: "VARCHAR(255)",
|
|
491
|
+
text: "TEXT",
|
|
492
|
+
integer: "INTEGER",
|
|
493
|
+
bigint: "BIGINT",
|
|
494
|
+
float: "REAL",
|
|
495
|
+
decimal: "DECIMAL",
|
|
496
|
+
boolean: "BOOLEAN",
|
|
497
|
+
date: "DATE",
|
|
498
|
+
datetime: "DATETIME",
|
|
499
|
+
timestamp: "TIMESTAMP",
|
|
500
|
+
time: "TIME",
|
|
501
|
+
binary: "BLOB",
|
|
502
|
+
json: "JSON"
|
|
503
|
+
}.freeze
|
|
504
|
+
|
|
505
|
+
# @param name [Symbol, String] table name
|
|
506
|
+
def initialize(name)
|
|
507
|
+
@name = name
|
|
508
|
+
@columns = []
|
|
509
|
+
@columns << "id INTEGER PRIMARY KEY AUTOINCREMENT"
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
# Add a column
|
|
513
|
+
#
|
|
514
|
+
# @param name [Symbol, String] column name
|
|
515
|
+
# @param type [Symbol] column type
|
|
516
|
+
# @param options [Hash] column options
|
|
517
|
+
def column(name, type, **options)
|
|
518
|
+
@columns << "#{name} #{self.class.sql_type(type, options)}"
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
# Column type shortcuts
|
|
522
|
+
%i[string text integer bigint float decimal boolean date datetime timestamp time binary json].each do |type|
|
|
523
|
+
define_method(type) do |name, **options|
|
|
524
|
+
column(name, type, **options)
|
|
525
|
+
end
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
# Add timestamps (created_at, updated_at)
|
|
529
|
+
def timestamps
|
|
530
|
+
@columns << "created_at DATETIME"
|
|
531
|
+
@columns << "updated_at DATETIME"
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
# Add foreign key reference
|
|
535
|
+
#
|
|
536
|
+
# @param table [Symbol, String] referenced table
|
|
537
|
+
# @param options [Hash] reference options
|
|
538
|
+
def references(table, foreign_key: true, **options)
|
|
539
|
+
column_name = "#{table}_id"
|
|
540
|
+
@columns << "#{column_name} INTEGER"
|
|
541
|
+
@columns << "FOREIGN KEY (#{column_name}) REFERENCES #{table}(id)" if foreign_key
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
# Generate SQL type string
|
|
545
|
+
#
|
|
546
|
+
# @param type [Symbol] column type
|
|
547
|
+
# @param options [Hash] column options
|
|
548
|
+
# @return [String]
|
|
549
|
+
def self.sql_type(type, options = {})
|
|
550
|
+
base = TYPE_MAP[type] || "VARCHAR(255)"
|
|
551
|
+
|
|
552
|
+
if type == :string && options[:limit]
|
|
553
|
+
base = "VARCHAR(#{options[:limit]})"
|
|
554
|
+
elsif type == :decimal && (options[:precision] || options[:scale])
|
|
555
|
+
precision = options[:precision] || 10
|
|
556
|
+
scale = options[:scale] || 0
|
|
557
|
+
base = "DECIMAL(#{precision}, #{scale})"
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
base += " NOT NULL" if options[:null] == false
|
|
561
|
+
base += " DEFAULT #{quote_default(options[:default])}" if options.key?(:default)
|
|
562
|
+
base
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
# Generate CREATE TABLE SQL
|
|
566
|
+
#
|
|
567
|
+
# @return [String]
|
|
568
|
+
def to_sql
|
|
569
|
+
"CREATE TABLE #{@name} (\n #{@columns.join(",\n ")}\n)"
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
private
|
|
573
|
+
|
|
574
|
+
def self.quote_default(value)
|
|
575
|
+
case value
|
|
576
|
+
when String then "'#{value}'"
|
|
577
|
+
when nil then "NULL"
|
|
578
|
+
when true then "1"
|
|
579
|
+
when false then "0"
|
|
580
|
+
else value.to_s
|
|
581
|
+
end
|
|
582
|
+
end
|
|
583
|
+
end
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
# Database-related error
|
|
587
|
+
class DatabaseError < Error; end
|
|
588
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarsier
|
|
4
|
+
# Base error class for all Tarsier errors
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when a route cannot be found
|
|
8
|
+
class RouteNotFoundError < Error
|
|
9
|
+
attr_reader :method, :path
|
|
10
|
+
|
|
11
|
+
def initialize(method, path)
|
|
12
|
+
@method = method
|
|
13
|
+
@path = path
|
|
14
|
+
super("No route matches [#{method}] #{path}")
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Raised when route definition is invalid
|
|
19
|
+
class InvalidRouteError < Error; end
|
|
20
|
+
|
|
21
|
+
# Raised when parameter validation fails
|
|
22
|
+
class ValidationError < Error
|
|
23
|
+
attr_reader :errors
|
|
24
|
+
|
|
25
|
+
def initialize(errors)
|
|
26
|
+
@errors = errors
|
|
27
|
+
super("Validation failed: #{errors.map { |k, v| "#{k} #{v.join(', ')}" }.join('; ')}")
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Raised when a required parameter is missing
|
|
32
|
+
class MissingParameterError < ValidationError
|
|
33
|
+
def initialize(param_name)
|
|
34
|
+
super({ param_name => ["is required"] })
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Raised when parameter type coercion fails
|
|
39
|
+
class TypeCoercionError < Error
|
|
40
|
+
attr_reader :param_name, :expected_type, :actual_value
|
|
41
|
+
|
|
42
|
+
def initialize(param_name, expected_type, actual_value)
|
|
43
|
+
@param_name = param_name
|
|
44
|
+
@expected_type = expected_type
|
|
45
|
+
@actual_value = actual_value
|
|
46
|
+
super("Cannot coerce '#{actual_value}' to #{expected_type} for parameter '#{param_name}'")
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Raised when middleware configuration is invalid
|
|
51
|
+
class MiddlewareError < Error; end
|
|
52
|
+
|
|
53
|
+
# Raised when controller action is not found
|
|
54
|
+
class ActionNotFoundError < Error
|
|
55
|
+
attr_reader :controller, :action
|
|
56
|
+
|
|
57
|
+
def initialize(controller, action)
|
|
58
|
+
@controller = controller
|
|
59
|
+
@action = action
|
|
60
|
+
super("Action '#{action}' not found in #{controller}")
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Raised when response has already been sent
|
|
65
|
+
class ResponseAlreadySentError < Error
|
|
66
|
+
def initialize
|
|
67
|
+
super("Response has already been sent")
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Raised when streaming is not supported
|
|
72
|
+
class StreamingNotSupportedError < Error
|
|
73
|
+
def initialize
|
|
74
|
+
super("Streaming is not supported by the current server")
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tarsier
|
|
4
|
+
module Middleware
|
|
5
|
+
# Base class for all middleware
|
|
6
|
+
# Provides a consistent interface for request/response processing
|
|
7
|
+
class Base
|
|
8
|
+
attr_reader :app, :options
|
|
9
|
+
|
|
10
|
+
# @param app [Object] the next middleware or application
|
|
11
|
+
# @param options [Hash] middleware options
|
|
12
|
+
def initialize(app, **options)
|
|
13
|
+
@app = app
|
|
14
|
+
@options = options
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Process the request
|
|
18
|
+
# @param request [Request] the request object
|
|
19
|
+
# @param response [Response] the response object
|
|
20
|
+
# @return [Response]
|
|
21
|
+
def call(request, response)
|
|
22
|
+
before(request, response)
|
|
23
|
+
result = @app.call(request, response)
|
|
24
|
+
after(request, result)
|
|
25
|
+
result
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
protected
|
|
29
|
+
|
|
30
|
+
# Called before the next middleware/app
|
|
31
|
+
# Override in subclasses
|
|
32
|
+
# @param request [Request]
|
|
33
|
+
# @param response [Response]
|
|
34
|
+
def before(request, response)
|
|
35
|
+
# Override in subclass
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Called after the next middleware/app
|
|
39
|
+
# Override in subclasses
|
|
40
|
+
# @param request [Request]
|
|
41
|
+
# @param response [Response]
|
|
42
|
+
def after(request, response)
|
|
43
|
+
# Override in subclass
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|