hamster-web 0.1.0.pre.2
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/LICENSE.txt +21 -0
- data/README.md +61 -0
- data/exe/hamster +24 -0
- data/lib/hamster/build/manifest.rb +53 -0
- data/lib/hamster/build/postgres_compatibility.rb +38 -0
- data/lib/hamster/build/query_accessors.rb +73 -0
- data/lib/hamster/build/view_compiler.rb +239 -0
- data/lib/hamster/cli.rb +166 -0
- data/lib/hamster/console.rb +34 -0
- data/lib/hamster/data/persistence.rb +152 -0
- data/lib/hamster/data/query.rb +106 -0
- data/lib/hamster/data/read/compiler.rb +101 -0
- data/lib/hamster/data/read/decoder.rb +101 -0
- data/lib/hamster/data/read/nodes.rb +118 -0
- data/lib/hamster/data/read/relation.rb +198 -0
- data/lib/hamster/data/read/schema.rb +40 -0
- data/lib/hamster/data/read/validator.rb +273 -0
- data/lib/hamster/data/record.rb +81 -0
- data/lib/hamster/data/table.rb +90 -0
- data/lib/hamster/data.rb +21 -0
- data/lib/hamster/database/admin.rb +39 -0
- data/lib/hamster/database/configuration.rb +84 -0
- data/lib/hamster/database/connection.rb +168 -0
- data/lib/hamster/database/database.rb +92 -0
- data/lib/hamster/database/errors.rb +21 -0
- data/lib/hamster/database/pool.rb +98 -0
- data/lib/hamster/database/postgres.rb +66 -0
- data/lib/hamster/database/result.rb +30 -0
- data/lib/hamster/schema_validation.rb +12 -0
- data/lib/hamster/web/action/base.rb +39 -0
- data/lib/hamster/web/application.rb +23 -0
- data/lib/hamster/web/context.rb +25 -0
- data/lib/hamster/web/html.rb +80 -0
- data/lib/hamster/web/http.rb +34 -0
- data/lib/hamster/web/instrumentation.rb +28 -0
- data/lib/hamster/web/router.rb +27 -0
- data/lib/hamster/web/routing/base.rb +131 -0
- data/lib/hamster/web/version.rb +7 -0
- data/lib/hamster/web/view/form.rb +119 -0
- data/lib/hamster/web/view.rb +196 -0
- data/lib/hamster/web.rb +13 -0
- data/templates/app/.gitignore +5 -0
- data/templates/app/AGENTS.md +8 -0
- data/templates/app/Gemfile +3 -0
- data/templates/app/README.md +32 -0
- data/templates/app/app/routes/home/index/action.rb +14 -0
- data/templates/app/app/routes/home/index/view.html.erb +2 -0
- data/templates/app/app/routes/mappings.rb +8 -0
- data/templates/app/app/views/layouts/application.html.erb +3 -0
- data/templates/app/bin/hamster +4 -0
- data/templates/app/bin/server +8 -0
- data/templates/app/config/application.rb +23 -0
- data/templates/app/config/commands.rb +16 -0
- data/templates/app/config/database.rb +20 -0
- data/templates/app/config/hamster.json +1 -0
- data/templates/app/db/migrations/.keep +0 -0
- data/templates/app/spin.lock +6 -0
- data/templates/app/spin.toml +2 -0
- metadata +133 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hamster
|
|
4
|
+
module Record
|
|
5
|
+
class Error < StandardError
|
|
6
|
+
attr_reader :result
|
|
7
|
+
|
|
8
|
+
def initialize(result)
|
|
9
|
+
@result = result
|
|
10
|
+
super(result.errors.join(", "))
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class ValidationError < Error; end
|
|
15
|
+
class ConflictError < Error; end
|
|
16
|
+
class NotFoundError < Error; end
|
|
17
|
+
class PersistenceError < Error; end
|
|
18
|
+
class MissingConnection < StandardError; end
|
|
19
|
+
|
|
20
|
+
class Result
|
|
21
|
+
attr_reader :record, :code, :errors, :cause
|
|
22
|
+
|
|
23
|
+
def initialize(record:, code: nil, errors: [], cause: nil)
|
|
24
|
+
@record = record
|
|
25
|
+
@code = code
|
|
26
|
+
@errors = errors
|
|
27
|
+
@cause = cause
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def success?
|
|
31
|
+
code.nil?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def value!
|
|
35
|
+
return record if success?
|
|
36
|
+
|
|
37
|
+
error_class = case code
|
|
38
|
+
when :invalid then ValidationError
|
|
39
|
+
when :conflict then ConflictError
|
|
40
|
+
when :not_found then NotFoundError
|
|
41
|
+
else PersistenceError
|
|
42
|
+
end
|
|
43
|
+
raise error_class.new(self)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.with_connection(connection)
|
|
48
|
+
previous = Thread.current[:hamster_record_connection]
|
|
49
|
+
Thread.current[:hamster_record_connection] = connection
|
|
50
|
+
yield
|
|
51
|
+
ensure
|
|
52
|
+
Thread.current[:hamster_record_connection] = previous
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.connection
|
|
56
|
+
Thread.current[:hamster_record_connection] ||
|
|
57
|
+
raise(MissingConnection, "Bind a database connection with Hamster::Record.with_connection")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def initialize(attributes = nil, **keywords)
|
|
62
|
+
source = attributes || keywords
|
|
63
|
+
@attributes = {}
|
|
64
|
+
source.each { |key, value| @attributes[key.to_sym] = value }
|
|
65
|
+
@partial = !attributes.nil?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def [](name)
|
|
69
|
+
key = name.to_sym
|
|
70
|
+
raise MissingAttributeError, "Unloaded attribute #{key}" if @partial && !@attributes.key?(key)
|
|
71
|
+
raise MissingAttributeError, "Undeclared attribute #{key}" unless self.class.columns.key?(key)
|
|
72
|
+
@attributes[key]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def write_attribute(name, value)
|
|
76
|
+
@attributes[name.to_sym] = value
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
class MissingAttributeError < StandardError; end
|
|
81
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hamster
|
|
4
|
+
class Table
|
|
5
|
+
include Record
|
|
6
|
+
include Persistence
|
|
7
|
+
extend Persistence::ClassMethods
|
|
8
|
+
|
|
9
|
+
REGISTRY = []
|
|
10
|
+
|
|
11
|
+
def self.inherited(child)
|
|
12
|
+
child.set_metadata(table_name, schema_name, columns) unless self == Table
|
|
13
|
+
REGISTRY << child
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.set_metadata(name, schema, definitions)
|
|
17
|
+
@table_name = name
|
|
18
|
+
@schema_name = schema
|
|
19
|
+
@columns = definitions.dup
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.registry
|
|
23
|
+
REGISTRY.dup.freeze
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.table(name, schema: "public")
|
|
27
|
+
@table_name = Read.identifier(name)
|
|
28
|
+
@schema_name = Read.identifier(schema)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.table_name
|
|
32
|
+
@table_name || name.split("::").last.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').gsub(/([a-z0-9])([A-Z])/, '\1_\2').downcase + "s"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.schema_name
|
|
36
|
+
@schema_name || "public"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.columns
|
|
40
|
+
(@columns || {}).dup.freeze
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.column(name, type:, nullable: true, enabled: true, write: :none)
|
|
44
|
+
register_column(name, type: type, nullable: nullable, enabled: enabled, write: write)
|
|
45
|
+
# Spinel expands these names from literal column declarations at compile time.
|
|
46
|
+
if RUBY_ENGINE == "ruby"
|
|
47
|
+
define_method("#{name}") { self[name] }
|
|
48
|
+
define_method("#{name}=") { |value| write_attribute(name, value) }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.register_column(name, type:, nullable:, enabled:, write:)
|
|
53
|
+
raise Read::ValidationError, "Unsupported column type #{type}" unless Read::TYPES.key?(type)
|
|
54
|
+
unless [true, false].include?(nullable) && [true, false].include?(enabled)
|
|
55
|
+
raise Read::ValidationError, "Column flags must be boolean"
|
|
56
|
+
end
|
|
57
|
+
unless [:none, :create, :update, :both].include?(write)
|
|
58
|
+
raise Read::ValidationError, "Column write must be :none, :create, :update, or :both"
|
|
59
|
+
end
|
|
60
|
+
key = Read.identifier(name).to_sym
|
|
61
|
+
unless key.to_s.match?(/\A[a-z_][a-z0-9_]*\z/)
|
|
62
|
+
raise Read::ValidationError, "Column #{key} cannot be used as a record accessor"
|
|
63
|
+
end
|
|
64
|
+
if Table.method_defined?(key) || Table.private_method_defined?(key) ||
|
|
65
|
+
Table.method_defined?("#{key}=") || Table.private_method_defined?("#{key}=")
|
|
66
|
+
raise Read::ValidationError, "Column #{key} would shadow a record method"
|
|
67
|
+
end
|
|
68
|
+
@columns = columns.merge(key => { type: type, nullable: nullable, enabled: enabled, write: write }.freeze)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.[](name); Read::Source.new(self)[name]; end
|
|
72
|
+
def self.as(name); Read::Source.new(self, name); end
|
|
73
|
+
def self.all; Read::Source.new(self).all; end
|
|
74
|
+
def self.first(connection: nil); all.first(connection: connection); end
|
|
75
|
+
def self.last(connection: nil); all.last(connection: connection); end
|
|
76
|
+
def self.count(connection: nil); all.count(connection: connection); end
|
|
77
|
+
def self.select(*items); all.select(*items); end
|
|
78
|
+
def self.reselect(*items); all.reselect(*items); end
|
|
79
|
+
def self.group(*items); all.group(*items); end
|
|
80
|
+
def self.order(*items); all.order(*items); end
|
|
81
|
+
def self.reorder(*items); all.reorder(*items); end
|
|
82
|
+
def self.where(value); all.where(value); end
|
|
83
|
+
def self.having(value); all.having(value); end
|
|
84
|
+
def self.limit(value); all.limit(value); end
|
|
85
|
+
def self.offset(value); all.offset(value); end
|
|
86
|
+
def self.distinct(enabled = true); all.distinct(enabled); end
|
|
87
|
+
def self.joins(source, on:); all.joins(source, on: on); end
|
|
88
|
+
def self.left_joins(source, on:); all.left_joins(source, on: on); end
|
|
89
|
+
end
|
|
90
|
+
end
|
data/lib/hamster/data.rb
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "web/instrumentation"
|
|
5
|
+
require_relative "database/errors"
|
|
6
|
+
require_relative "database/configuration"
|
|
7
|
+
require_relative "database/result"
|
|
8
|
+
require_relative "database/connection"
|
|
9
|
+
require_relative "database/pool"
|
|
10
|
+
require_relative "database/admin"
|
|
11
|
+
require_relative "database/database"
|
|
12
|
+
require_relative "data/record"
|
|
13
|
+
require_relative "data/persistence"
|
|
14
|
+
require_relative "data/read/nodes"
|
|
15
|
+
require_relative "data/read/relation"
|
|
16
|
+
require_relative "data/read/validator"
|
|
17
|
+
require_relative "data/read/compiler"
|
|
18
|
+
require_relative "data/read/decoder"
|
|
19
|
+
require_relative "data/table"
|
|
20
|
+
require_relative "data/query"
|
|
21
|
+
require_relative "data/read/schema"
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hamster
|
|
4
|
+
class Database
|
|
5
|
+
class Admin
|
|
6
|
+
def self.create!(configuration)
|
|
7
|
+
connection = Connection.new(Postgres.connect(admin_configuration(configuration)))
|
|
8
|
+
existing = connection.execute("SELECT 1 FROM pg_database WHERE datname = $1", [configuration.database])
|
|
9
|
+
return false unless existing.rows.empty?
|
|
10
|
+
|
|
11
|
+
connection.execute_script("CREATE DATABASE #{quote_identifier(configuration.database)}")
|
|
12
|
+
true
|
|
13
|
+
ensure
|
|
14
|
+
connection&.close
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.admin_configuration(configuration)
|
|
18
|
+
Configuration.new(
|
|
19
|
+
adapter: configuration.adapter,
|
|
20
|
+
host: configuration.host,
|
|
21
|
+
port: configuration.port,
|
|
22
|
+
database: configuration.admin_database,
|
|
23
|
+
username: configuration.admin_username,
|
|
24
|
+
password: configuration.admin_password,
|
|
25
|
+
pool_size: 1,
|
|
26
|
+
checkout_timeout: configuration.checkout_timeout
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.quote_identifier(identifier)
|
|
31
|
+
raise ConfigurationError, "unsafe database name" unless identifier.match?(/\A[a-z_][a-z0-9_]*\z/)
|
|
32
|
+
|
|
33
|
+
"\"#{identifier}\""
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private_class_method :admin_configuration, :quote_identifier
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hamster
|
|
4
|
+
class Database
|
|
5
|
+
class Configuration
|
|
6
|
+
attr_reader :adapter, :host, :port, :database, :username, :password,
|
|
7
|
+
:pool_size, :checkout_timeout, :admin_database,
|
|
8
|
+
:admin_username, :admin_password
|
|
9
|
+
|
|
10
|
+
def initialize(adapter:, host:, port:, database:, username:, password:, pool_size:, checkout_timeout:,
|
|
11
|
+
admin_database: "postgres", admin_username: nil, admin_password: nil)
|
|
12
|
+
@adapter = adapter.to_s
|
|
13
|
+
@host = host.to_s
|
|
14
|
+
@port = integer_value(port, "port")
|
|
15
|
+
@database = database.to_s
|
|
16
|
+
@username = username.to_s
|
|
17
|
+
@password = password.to_s
|
|
18
|
+
@pool_size = integer_value(pool_size, "pool_size")
|
|
19
|
+
@checkout_timeout = float_value(checkout_timeout, "checkout_timeout")
|
|
20
|
+
@admin_database = admin_database.to_s
|
|
21
|
+
@admin_username = (admin_username || username).to_s
|
|
22
|
+
@admin_password = (admin_password || password).to_s
|
|
23
|
+
validate!
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.from_env(env = ENV, app_name: "hamster")
|
|
27
|
+
if env.key?("DATABASE_TLS") || env.key?("DATABASE_SSLMODE")
|
|
28
|
+
raise ConfigurationError, "database TLS settings are unsupported by the postgres adapter"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
adapter = env.fetch("DATABASE_ADAPTER", "postgres").dup
|
|
32
|
+
host = env.fetch("DATABASE_HOST", "127.0.0.1").dup
|
|
33
|
+
port = env.fetch("DATABASE_PORT", "5432").dup
|
|
34
|
+
app_name = env.fetch("HAMSTER_APP_NAME", app_name).dup
|
|
35
|
+
environment = env.fetch("HAMSTER_ENV", "development").dup
|
|
36
|
+
database = env.fetch("DATABASE_NAME", "#{app_name}_#{environment}").dup
|
|
37
|
+
username = env.fetch("DATABASE_USERNAME").dup
|
|
38
|
+
password = env.fetch("DATABASE_PASSWORD", "").dup
|
|
39
|
+
admin_database = env.fetch("DATABASE_ADMIN_NAME", "postgres").dup
|
|
40
|
+
admin_username = env.fetch("DATABASE_ADMIN_USERNAME", username).dup
|
|
41
|
+
admin_password = env.fetch("DATABASE_ADMIN_PASSWORD", password).dup
|
|
42
|
+
pool_size = env.fetch("DATABASE_POOL_SIZE", "5").dup
|
|
43
|
+
checkout_timeout = env.fetch("DATABASE_CHECKOUT_TIMEOUT", "5.0").dup
|
|
44
|
+
new(
|
|
45
|
+
adapter: adapter, host: host, port: port, database: database,
|
|
46
|
+
username: username, password: password, pool_size: pool_size,
|
|
47
|
+
checkout_timeout: checkout_timeout, admin_database: admin_database,
|
|
48
|
+
admin_username: admin_username, admin_password: admin_password
|
|
49
|
+
)
|
|
50
|
+
rescue KeyError => error
|
|
51
|
+
raise ConfigurationError, "missing database setting #{error.key}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def integer_value(value, name)
|
|
57
|
+
Integer(value)
|
|
58
|
+
rescue ArgumentError, TypeError
|
|
59
|
+
raise ConfigurationError, "database #{name} must be an integer"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def float_value(value, name)
|
|
63
|
+
Float(value)
|
|
64
|
+
rescue ArgumentError, TypeError
|
|
65
|
+
raise ConfigurationError, "database #{name} must be a number"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def validate!
|
|
69
|
+
raise ConfigurationError, "unsupported database adapter #{adapter.inspect}" unless adapter == "postgres"
|
|
70
|
+
raise ConfigurationError, "database host must not be empty" if host.empty?
|
|
71
|
+
raise ConfigurationError, "database port must be between 1 and 65535" unless port.between?(1, 65_535)
|
|
72
|
+
raise ConfigurationError, "database name must not be empty" if database.empty?
|
|
73
|
+
unless database.match?(/\A[a-z_][a-z0-9_]*\z/)
|
|
74
|
+
raise ConfigurationError, "database name must be a lowercase PostgreSQL identifier"
|
|
75
|
+
end
|
|
76
|
+
raise ConfigurationError, "database admin name must not be empty" if admin_database.empty?
|
|
77
|
+
raise ConfigurationError, "database admin username must not be empty" if admin_username.empty?
|
|
78
|
+
raise ConfigurationError, "database username must not be empty" if username.empty?
|
|
79
|
+
raise ConfigurationError, "database pool_size must be positive" unless pool_size.positive?
|
|
80
|
+
raise ConfigurationError, "database checkout_timeout must be positive" unless checkout_timeout.positive?
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
module Hamster
|
|
5
|
+
class Database
|
|
6
|
+
class Connection
|
|
7
|
+
attr_reader :raw
|
|
8
|
+
|
|
9
|
+
def initialize(raw)
|
|
10
|
+
@raw = raw
|
|
11
|
+
@usable = true
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def execute(sql, params = [])
|
|
15
|
+
Hamster::Instrumentation.instrument("db.execute", attributes: {"db.system.name" => "postgresql", "db.operation.name" => Hamster::Instrumentation.sql_operation(sql)}, kind: "client") do |details|
|
|
16
|
+
begin
|
|
17
|
+
execute_statement(sql, params)
|
|
18
|
+
rescue Error => error
|
|
19
|
+
Database.telemetry_error(details, error)
|
|
20
|
+
raise
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def execute_statement(sql, params)
|
|
26
|
+
values = serialize_params(params)
|
|
27
|
+
result = raw.exec_params(sql, values)
|
|
28
|
+
result_object(result)
|
|
29
|
+
rescue ArgumentError => error
|
|
30
|
+
raise error
|
|
31
|
+
rescue PG::Error => error
|
|
32
|
+
mark_unusable unless usable_status?
|
|
33
|
+
raise QueryError.new("database query failed", original: error,
|
|
34
|
+
sqlstate: Postgres.sqlstate(error))
|
|
35
|
+
rescue StandardError => error
|
|
36
|
+
mark_unusable unless usable_status?
|
|
37
|
+
raise error if error.is_a?(Error)
|
|
38
|
+
|
|
39
|
+
raise ConnectionError.new("database operation failed: #{error.message}", original: error)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def execute_script(sql)
|
|
43
|
+
Hamster::Instrumentation.instrument("db.execute_script", attributes: {"db.system.name" => "postgresql", "db.operation.name" => "SCRIPT"}, kind: "client") do |details|
|
|
44
|
+
begin
|
|
45
|
+
execute_script_statement(sql)
|
|
46
|
+
rescue Error => error
|
|
47
|
+
Database.telemetry_error(details, error)
|
|
48
|
+
raise
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def execute_script_statement(sql)
|
|
54
|
+
result_object(raw.exec(sql))
|
|
55
|
+
rescue PG::Error => error
|
|
56
|
+
mark_unusable unless usable_status?
|
|
57
|
+
raise QueryError.new("database query failed", original: error,
|
|
58
|
+
sqlstate: Postgres.sqlstate(error))
|
|
59
|
+
rescue StandardError => error
|
|
60
|
+
mark_unusable unless usable_status?
|
|
61
|
+
raise error if error.is_a?(Error)
|
|
62
|
+
|
|
63
|
+
raise ConnectionError.new("database operation failed: #{error.message}", original: error)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def transaction
|
|
67
|
+
raise TransactionError, "nested transactions are not supported" unless idle?
|
|
68
|
+
|
|
69
|
+
execute("BEGIN")
|
|
70
|
+
begin
|
|
71
|
+
result = yield self
|
|
72
|
+
execute("COMMIT")
|
|
73
|
+
result
|
|
74
|
+
rescue StandardError => original
|
|
75
|
+
begin
|
|
76
|
+
execute("ROLLBACK") unless idle?
|
|
77
|
+
rescue StandardError
|
|
78
|
+
mark_unusable
|
|
79
|
+
end
|
|
80
|
+
raise original
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def idle?
|
|
85
|
+
raw.transaction_status == PG::PQTRANS_IDLE
|
|
86
|
+
rescue StandardError
|
|
87
|
+
false
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def usable?
|
|
91
|
+
@usable && raw.transaction_status != PG::PQTRANS_UNKNOWN
|
|
92
|
+
rescue StandardError
|
|
93
|
+
false
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def close
|
|
97
|
+
raw.close unless raw.transaction_status == PG::PQTRANS_UNKNOWN
|
|
98
|
+
rescue StandardError
|
|
99
|
+
mark_unusable
|
|
100
|
+
ensure
|
|
101
|
+
@usable = false
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def mark_unusable
|
|
105
|
+
@usable = false
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
private :execute_statement, :execute_script_statement
|
|
111
|
+
|
|
112
|
+
def usable_status?
|
|
113
|
+
raw.transaction_status != PG::PQTRANS_UNKNOWN
|
|
114
|
+
rescue StandardError
|
|
115
|
+
false
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def serialize_params(params)
|
|
119
|
+
raise ArgumentError, "database parameters must be an Array" unless params.is_a?(Array)
|
|
120
|
+
|
|
121
|
+
params.map do |value|
|
|
122
|
+
case value
|
|
123
|
+
when NilClass, String then value
|
|
124
|
+
when Integer then value.to_s
|
|
125
|
+
when Time then value.iso8601(6)
|
|
126
|
+
when DateTime then value.iso8601(6)
|
|
127
|
+
when Date then value.iso8601
|
|
128
|
+
when TrueClass then "true"
|
|
129
|
+
when FalseClass then "false"
|
|
130
|
+
else
|
|
131
|
+
raise ArgumentError, "unsupported database parameter #{value.class}"
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def copy_rows(result)
|
|
137
|
+
rows = []
|
|
138
|
+
row_index = 0
|
|
139
|
+
while row_index < result.ntuples
|
|
140
|
+
row = []
|
|
141
|
+
column_index = 0
|
|
142
|
+
while column_index < result.nfields
|
|
143
|
+
row << result.getvalue(row_index, column_index)
|
|
144
|
+
column_index += 1
|
|
145
|
+
end
|
|
146
|
+
rows << row
|
|
147
|
+
row_index += 1
|
|
148
|
+
end
|
|
149
|
+
rows
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def result_object(result)
|
|
153
|
+
Result.new(columns: result.fields.dup, rows: copy_rows(result), types: copy_types(result),
|
|
154
|
+
command_tag: result.cmd_tag)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def copy_types(result)
|
|
158
|
+
types = []
|
|
159
|
+
column_index = 0
|
|
160
|
+
while column_index < result.nfields
|
|
161
|
+
types << result.ftype(column_index)
|
|
162
|
+
column_index += 1
|
|
163
|
+
end
|
|
164
|
+
types
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hamster
|
|
4
|
+
class Database
|
|
5
|
+
attr_reader :configuration
|
|
6
|
+
|
|
7
|
+
def initialize(configuration, migrations_path:)
|
|
8
|
+
@configuration = configuration
|
|
9
|
+
@migrations_path = File.expand_path(migrations_path)
|
|
10
|
+
@pool = Pool.new(configuration)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Database boundaries own SQLSTATE classification, never the telemetry core.
|
|
14
|
+
def self.telemetry_error(details, error)
|
|
15
|
+
state = error.sqlstate
|
|
16
|
+
if state.instance_of?(String) && state.valid_encoding? && state.match?(/\A[0-9A-Z]{5}\z/)
|
|
17
|
+
details["db.response.status_code"] = state
|
|
18
|
+
end
|
|
19
|
+
rescue StandardError
|
|
20
|
+
Hamster::Instrumentation.diagnostic
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def with_connection
|
|
24
|
+
connection = Hamster::Instrumentation.instrument("db.checkout", attributes: {"db.system.name" => "postgresql"}) do |details|
|
|
25
|
+
begin
|
|
26
|
+
@pool.checkout
|
|
27
|
+
rescue Error => error
|
|
28
|
+
Database.telemetry_error(details, error)
|
|
29
|
+
raise
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
begin
|
|
33
|
+
Hamster::Record.with_connection(connection) { yield connection }
|
|
34
|
+
ensure
|
|
35
|
+
@pool.checkin(connection)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def migrate!
|
|
40
|
+
with_connection do |connection|
|
|
41
|
+
connection.transaction do
|
|
42
|
+
connection.execute(<<~SQL)
|
|
43
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
44
|
+
version TEXT PRIMARY KEY,
|
|
45
|
+
applied_at TIMESTAMPTZ NOT NULL
|
|
46
|
+
)
|
|
47
|
+
SQL
|
|
48
|
+
|
|
49
|
+
applied = rows(connection, "SELECT version FROM schema_migrations ORDER BY version")
|
|
50
|
+
migration_files.each do |version, path|
|
|
51
|
+
next if applied.any? { |row| row["version"] == version }
|
|
52
|
+
|
|
53
|
+
connection.execute_script(File.read(path))
|
|
54
|
+
connection.execute("INSERT INTO schema_migrations (version, applied_at) VALUES ($1, $2)",
|
|
55
|
+
[version, Time.now.utc.iso8601])
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
true
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def verify!
|
|
63
|
+
with_connection do |connection|
|
|
64
|
+
present = rows(connection, "SELECT to_regclass('public.schema_migrations') AS name")
|
|
65
|
+
raise ConfigurationError, "migration tracking is missing; run bin/hamster db:migrate" if present.first["name"].nil?
|
|
66
|
+
applied = rows(connection, "SELECT version FROM schema_migrations ORDER BY version").map { |row| row["version"] }
|
|
67
|
+
missing = migration_files.map(&:first) - applied
|
|
68
|
+
raise ConfigurationError, "pending migrations #{missing.join(', ')}; run bin/hamster db:migrate" unless missing.empty?
|
|
69
|
+
end
|
|
70
|
+
true
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def shutdown
|
|
74
|
+
@pool.shutdown
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def rows(connection, sql, params = [])
|
|
80
|
+
connection.execute(sql, params).hashes
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def migration_files
|
|
84
|
+
files = Dir[File.join(@migrations_path, "*.sql")].sort.map do |path|
|
|
85
|
+
[File.basename(path, ".sql").split("_", 2).first, path]
|
|
86
|
+
end
|
|
87
|
+
versions = files.map(&:first)
|
|
88
|
+
raise ConfigurationError, "duplicate migration versions" unless versions.uniq.length == versions.length
|
|
89
|
+
files
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hamster
|
|
4
|
+
class Database
|
|
5
|
+
class Error < StandardError
|
|
6
|
+
attr_reader :original, :sqlstate
|
|
7
|
+
|
|
8
|
+
def initialize(message, original: nil, sqlstate: nil)
|
|
9
|
+
super(message)
|
|
10
|
+
@original = original
|
|
11
|
+
@sqlstate = sqlstate
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
class ConfigurationError < Error; end
|
|
16
|
+
class CheckoutTimeout < Error; end
|
|
17
|
+
class ConnectionError < Error; end
|
|
18
|
+
class QueryError < Error; end
|
|
19
|
+
class TransactionError < Error; end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hamster
|
|
4
|
+
class Database
|
|
5
|
+
class Pool
|
|
6
|
+
def initialize(configuration)
|
|
7
|
+
@configuration = configuration
|
|
8
|
+
@lock = Mutex.new
|
|
9
|
+
@ready = ConditionVariable.new
|
|
10
|
+
@available = []
|
|
11
|
+
@total = 0
|
|
12
|
+
@borrowed = 0
|
|
13
|
+
@closed = false
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def checkout
|
|
17
|
+
connection = reserve
|
|
18
|
+
begin
|
|
19
|
+
connection ||= Connection.new(Postgres.connect(@configuration))
|
|
20
|
+
rescue StandardError
|
|
21
|
+
release_failed_reservation
|
|
22
|
+
raise
|
|
23
|
+
end
|
|
24
|
+
connection
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def checkin(connection)
|
|
28
|
+
reusable = connection.usable?
|
|
29
|
+
if reusable && !connection.idle?
|
|
30
|
+
begin
|
|
31
|
+
connection.execute("ROLLBACK")
|
|
32
|
+
reusable = connection.idle?
|
|
33
|
+
rescue StandardError
|
|
34
|
+
reusable = false
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
connection.close unless reusable
|
|
39
|
+
@lock.synchronize do
|
|
40
|
+
@borrowed -= 1
|
|
41
|
+
if reusable && !@closed
|
|
42
|
+
@available << connection
|
|
43
|
+
else
|
|
44
|
+
@total -= 1
|
|
45
|
+
end
|
|
46
|
+
@ready.broadcast
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def shutdown
|
|
51
|
+
available = @lock.synchronize do
|
|
52
|
+
@closed = true
|
|
53
|
+
connections = @available
|
|
54
|
+
@available = []
|
|
55
|
+
@ready.broadcast
|
|
56
|
+
connections
|
|
57
|
+
end
|
|
58
|
+
available.each(&:close)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def reserve
|
|
64
|
+
deadline = Time.now + @configuration.checkout_timeout
|
|
65
|
+
@lock.synchronize do
|
|
66
|
+
loop do
|
|
67
|
+
raise ConnectionError, "database pool is shut down" if @closed
|
|
68
|
+
|
|
69
|
+
connection = @available.pop
|
|
70
|
+
if connection
|
|
71
|
+
@borrowed += 1
|
|
72
|
+
return connection
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
if @total < @configuration.pool_size
|
|
76
|
+
@total += 1
|
|
77
|
+
@borrowed += 1
|
|
78
|
+
return nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
remaining = deadline - Time.now
|
|
82
|
+
raise CheckoutTimeout, "database pool checkout timed out" unless remaining.positive?
|
|
83
|
+
|
|
84
|
+
@ready.wait(@lock, remaining)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def release_failed_reservation
|
|
90
|
+
@lock.synchronize do
|
|
91
|
+
@total -= 1
|
|
92
|
+
@borrowed -= 1
|
|
93
|
+
@ready.broadcast
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|