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
data/lib/hamster/cli.rb
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "web/version"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
require "tmpdir"
|
|
7
|
+
require "open3"
|
|
8
|
+
require "shellwords"
|
|
9
|
+
require_relative "build/manifest"
|
|
10
|
+
require_relative "build/postgres_compatibility"
|
|
11
|
+
|
|
12
|
+
module Hamster
|
|
13
|
+
module CLI
|
|
14
|
+
def self.run(arguments)
|
|
15
|
+
args = arguments.dup
|
|
16
|
+
command = args.shift
|
|
17
|
+
case command
|
|
18
|
+
when "--version"
|
|
19
|
+
puts Web::VERSION
|
|
20
|
+
0
|
|
21
|
+
when "new"
|
|
22
|
+
raise ArgumentError, "Usage: hamster new AppName" unless args.length == 1
|
|
23
|
+
Generator.create(args.first)
|
|
24
|
+
0
|
|
25
|
+
when nil, "help", "--help", "-h"
|
|
26
|
+
puts "Usage: hamster new AppName | setup | manifest | manifest:check | build | serve | routes | console | db:create | db:migrate | db:validate"
|
|
27
|
+
0
|
|
28
|
+
else
|
|
29
|
+
raise ArgumentError, "Unexpected arguments: #{args.join(' ')}" unless args.empty?
|
|
30
|
+
dispatch(command)
|
|
31
|
+
end
|
|
32
|
+
rescue StandardError, Gem::LoadError => error
|
|
33
|
+
warn error.message
|
|
34
|
+
1
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.root
|
|
38
|
+
path = Dir.pwd
|
|
39
|
+
loop do
|
|
40
|
+
return path if File.file?(File.join(path, "config/hamster.json"))
|
|
41
|
+
parent = File.dirname(path)
|
|
42
|
+
raise "No Hamster application found" if parent == path
|
|
43
|
+
path = parent
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.dispatch(command)
|
|
48
|
+
app_root = root
|
|
49
|
+
metadata = JSON.parse(File.read(File.join(app_root, "config/hamster.json")))
|
|
50
|
+
binary = ENV.fetch("HAMSTER_SERVER_BINARY", File.join(app_root, "bin", "#{metadata.fetch('name')}-server"))
|
|
51
|
+
case command
|
|
52
|
+
when "console", "c", "db:validate"
|
|
53
|
+
adapter = File.join(app_root, "config/commands.rb")
|
|
54
|
+
raise "Missing config/commands.rb; add the command adapter from the current app template" unless File.file?(adapter)
|
|
55
|
+
require adapter
|
|
56
|
+
if command == "db:validate"
|
|
57
|
+
require_relative "schema_validation"
|
|
58
|
+
issues = SchemaValidation.run(ApplicationCommands.database)
|
|
59
|
+
raise issues.join("\n") unless issues.empty?
|
|
60
|
+
puts "Database declarations validated (#{Table.registry.length} tables); no migrations were run."
|
|
61
|
+
else
|
|
62
|
+
require_relative "console"
|
|
63
|
+
Console.run(ApplicationCommands.build)
|
|
64
|
+
end
|
|
65
|
+
when "setup"
|
|
66
|
+
Dir.chdir(app_root) { raise "Dependency installation failed" unless system("bundle", "install") }
|
|
67
|
+
Dir.chdir(app_root) { raise "Spin dependency vendoring failed" unless system("spin", "vendor") } if File.file?(File.join(app_root, "spin.toml"))
|
|
68
|
+
Build::PostgresCompatibility.apply(app_root) if File.file?(File.join(app_root, "spin.toml"))
|
|
69
|
+
when "db:create", "db:migrate"
|
|
70
|
+
output, status = Open3.capture2e("spin", "list", "--json", chdir: app_root)
|
|
71
|
+
raise "Cannot resolve PostgreSQL dependency: #{output}" unless status.success?
|
|
72
|
+
pg = JSON.parse(output).find { |package| package["name"] == "pg" }
|
|
73
|
+
raise "PostgreSQL dependency missing; configure pg in spin.toml and run setup" unless pg
|
|
74
|
+
driver = File.join(app_root, "vendor/packages", "pg-#{pg.fetch('version')}")
|
|
75
|
+
raise "PostgreSQL dependency not vendored; run bin/hamster setup" unless File.file?(File.join(driver, "pg/client.rb"))
|
|
76
|
+
$LOAD_PATH.unshift(driver)
|
|
77
|
+
require_relative "database/postgres"
|
|
78
|
+
configuration = Hamster::Database::Configuration.from_env(app_name: metadata.fetch("name"))
|
|
79
|
+
if command == "db:create"
|
|
80
|
+
created = Hamster::Database::Admin.create!(configuration)
|
|
81
|
+
puts(created ? "Database created: #{configuration.database}" : "Database already exists: #{configuration.database}")
|
|
82
|
+
else
|
|
83
|
+
database = Hamster::Database.new(configuration, migrations_path: File.join(app_root, "db/migrations"))
|
|
84
|
+
begin
|
|
85
|
+
database.migrate!
|
|
86
|
+
puts "Database migrated: #{configuration.database}"
|
|
87
|
+
ensure
|
|
88
|
+
database.shutdown
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
when "manifest"
|
|
92
|
+
Build::Manifest.generate(app_root)
|
|
93
|
+
when "manifest:check"
|
|
94
|
+
issues = Build::Manifest.check(app_root)
|
|
95
|
+
raise issues.join("\n") unless issues.empty?
|
|
96
|
+
puts "Manifest check passed"
|
|
97
|
+
when "build"
|
|
98
|
+
Build::PostgresCompatibility.apply(app_root) if File.file?(File.join(app_root, "spin.toml"))
|
|
99
|
+
Build::Manifest.generate(app_root)
|
|
100
|
+
issues = Build::Manifest.check(app_root)
|
|
101
|
+
raise issues.join("\n") unless issues.empty?
|
|
102
|
+
spinner = Gem::Specification.find_by_name("spinel-spinner").full_gem_path
|
|
103
|
+
temporary = binary + ".tmp"
|
|
104
|
+
begin
|
|
105
|
+
Dir.chdir(app_root) do
|
|
106
|
+
flags = ["--require-gate", "-I", File.expand_path("..", __dir__), "-I", File.join(spinner, "lib"), "-I", "."]
|
|
107
|
+
if File.file?("spin.toml")
|
|
108
|
+
output, diagnostic, status = Open3.capture3("spin", "flags")
|
|
109
|
+
raise "Spin dependency resolution failed: #{diagnostic}" unless status.success?
|
|
110
|
+
flags.concat(Shellwords.split(output))
|
|
111
|
+
end
|
|
112
|
+
raise "Spinel compilation failed" unless system(ENV.fetch("SPINEL", "spinel"), "bin/server", *flags, "-o", temporary)
|
|
113
|
+
end
|
|
114
|
+
File.rename(temporary, binary)
|
|
115
|
+
ensure
|
|
116
|
+
File.delete(temporary) if File.file?(temporary)
|
|
117
|
+
end
|
|
118
|
+
puts "Built #{binary}"
|
|
119
|
+
when "serve", "server", "s"
|
|
120
|
+
raise "Server binary missing; run bin/hamster build" unless File.executable?(binary)
|
|
121
|
+
Dir.chdir(app_root) { exec(binary) }
|
|
122
|
+
when "routes"
|
|
123
|
+
require File.join(app_root, "config/application")
|
|
124
|
+
Hamster::View.route_mapping.routes.each do |route|
|
|
125
|
+
puts [route.method, route.pattern, route.name, route.action.name].join(" ")
|
|
126
|
+
end
|
|
127
|
+
else
|
|
128
|
+
raise ArgumentError, "Unknown command: #{command}"
|
|
129
|
+
end
|
|
130
|
+
0
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
module Generator
|
|
135
|
+
def self.create(input)
|
|
136
|
+
unless input.match?(/\A(?:[A-Z][A-Za-z0-9]*|[a-z][a-z0-9]*(?:_[a-z0-9]+)*)\z/)
|
|
137
|
+
raise ArgumentError, "Use a PascalCase or snake_case application name"
|
|
138
|
+
end
|
|
139
|
+
name = input.gsub(/([A-Z]+)([A-Z][a-z])/, '\\1_\\2').gsub(/([a-z0-9])([A-Z])/, '\\1_\\2').downcase
|
|
140
|
+
namespace = name.split("_").map(&:capitalize).join
|
|
141
|
+
raise ArgumentError, "Reserved application name" if %w[Hamster Spinner Object Kernel BasicObject Class Module].include?(namespace)
|
|
142
|
+
destination = File.expand_path(name)
|
|
143
|
+
raise ArgumentError, "Destination already exists: #{destination}" if File.exist?(destination) || File.symlink?(destination)
|
|
144
|
+
template = File.expand_path("../../templates/app", __dir__)
|
|
145
|
+
Dir.mktmpdir(".hamster-", File.dirname(destination)) do |container|
|
|
146
|
+
staging = File.join(container, "app")
|
|
147
|
+
FileUtils.mkdir_p(staging)
|
|
148
|
+
Dir.glob("**/*", File::FNM_DOTMATCH, base: template).each do |relative|
|
|
149
|
+
next if [".", ".."].include?(File.basename(relative))
|
|
150
|
+
source = File.join(template, relative)
|
|
151
|
+
target = File.join(staging, relative)
|
|
152
|
+
if File.directory?(source)
|
|
153
|
+
FileUtils.mkdir_p(target)
|
|
154
|
+
else
|
|
155
|
+
File.write(target, File.read(source).gsub("__APP_MODULE__", namespace).gsub("__APP_NAME__", name).gsub("__HAMSTER_VERSION__", Web::VERSION))
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
Build::Manifest.generate(staging)
|
|
159
|
+
File.chmod(0755, File.join(staging, "bin/hamster"))
|
|
160
|
+
raise "Destination appeared during generation" if File.exist?(destination)
|
|
161
|
+
File.rename(staging, destination)
|
|
162
|
+
end
|
|
163
|
+
puts "Created #{name}. Next: cd #{name} && bin/hamster setup"
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hamster
|
|
4
|
+
module Console
|
|
5
|
+
def self.run(application)
|
|
6
|
+
app = application
|
|
7
|
+
database = application.database
|
|
8
|
+
puts "Hamster console (app, application, database)"
|
|
9
|
+
if database
|
|
10
|
+
database.with_connection do |connection|
|
|
11
|
+
workspace = binding
|
|
12
|
+
if block_given?
|
|
13
|
+
yield workspace
|
|
14
|
+
else
|
|
15
|
+
start(workspace)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
elsif block_given?
|
|
19
|
+
yield binding
|
|
20
|
+
else
|
|
21
|
+
start(binding)
|
|
22
|
+
end
|
|
23
|
+
ensure
|
|
24
|
+
application.shutdown
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.start(workspace)
|
|
28
|
+
require "irb"
|
|
29
|
+
IRB.setup(__FILE__, argv: [])
|
|
30
|
+
IRB::Irb.new(IRB::WorkSpace.new(workspace)).run(IRB.conf)
|
|
31
|
+
end
|
|
32
|
+
private_class_method :start
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hamster
|
|
4
|
+
module Persistence
|
|
5
|
+
include Record
|
|
6
|
+
|
|
7
|
+
module ClassMethods
|
|
8
|
+
include Record
|
|
9
|
+
|
|
10
|
+
def connection
|
|
11
|
+
Record.connection
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def write_table_name
|
|
15
|
+
if schema_name == "public" && table_name.match?(/\A[a-z_][a-z0-9_]*\z/)
|
|
16
|
+
table_name
|
|
17
|
+
else
|
|
18
|
+
%Q("#{schema_name.gsub('"', '""')}"."#{table_name.gsub('"', '""')}")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def create_fields
|
|
23
|
+
columns.select { |_name, definition| [:create, :both].include?(definition[:write]) }.keys.map(&:to_s)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def update_fields
|
|
27
|
+
columns.select { |_name, definition| [:update, :both].include?(definition[:write]) }.keys.map(&:to_s)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def create(**attributes)
|
|
31
|
+
Instrumentation.record(name, "create") { persist_create(**attributes) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Shared persistence path; the caller owns the record boundary.
|
|
35
|
+
def persist_create(**attributes)
|
|
36
|
+
check_fields!(attributes, create_fields)
|
|
37
|
+
record = new(**attributes)
|
|
38
|
+
errors = validate_create(attributes)
|
|
39
|
+
return Result.new(record: record, code: :invalid, errors: errors) unless errors.empty?
|
|
40
|
+
|
|
41
|
+
now = Time.now.utc.iso8601
|
|
42
|
+
timestamps = %w[created_at updated_at].select { |name| self.columns.key?(name.to_sym) }
|
|
43
|
+
columns = create_fields + timestamps
|
|
44
|
+
values = create_fields.map { |name| attributes[name.to_sym] } + timestamps.map { now }
|
|
45
|
+
placeholders = (1..columns.length).map { |number| "$#{number}" }.join(", ")
|
|
46
|
+
sql = "INSERT INTO #{write_table_name} (#{columns.join(', ')}) VALUES (#{placeholders}) " \
|
|
47
|
+
"RETURNING #{(['id'] + timestamps).join(', ')}"
|
|
48
|
+
row = connection.execute(sql, values).hashes.first
|
|
49
|
+
return Result.new(record: record, code: :database_error, errors: ["Could not save record."]) unless row
|
|
50
|
+
|
|
51
|
+
record.id = row["id"]
|
|
52
|
+
record.write_attribute(:created_at, row["created_at"]) if self.columns.key?(:created_at)
|
|
53
|
+
record.write_attribute(:updated_at, row["updated_at"]) if self.columns.key?(:updated_at)
|
|
54
|
+
Result.new(record: record)
|
|
55
|
+
rescue Hamster::Database::Error => error
|
|
56
|
+
database_failure(record, error)
|
|
57
|
+
end
|
|
58
|
+
private :persist_create
|
|
59
|
+
|
|
60
|
+
def create!(**attributes)
|
|
61
|
+
create(**attributes).value!
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def validate_create(_attributes)
|
|
65
|
+
[]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def check_fields!(attributes, allowed)
|
|
69
|
+
unknown = attributes.keys.map(&:to_s) - allowed
|
|
70
|
+
raise ArgumentError, "Undeclared record fields: #{unknown.join(', ')}" unless unknown.empty?
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def database_failure(record, error)
|
|
74
|
+
if error.is_a?(Hamster::Database::QueryError) && error.sqlstate == "23505"
|
|
75
|
+
Result.new(record: record, code: :conflict, errors: ["Record already exists."], cause: error)
|
|
76
|
+
else
|
|
77
|
+
Result.new(record: record, code: :database_error, errors: ["Could not save record."], cause: error)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def update(**changes)
|
|
83
|
+
Instrumentation.record(self.class.name, "update") do
|
|
84
|
+
self.class.check_fields!(changes, self.class.update_fields)
|
|
85
|
+
next missing_result if id.nil? || destroyed?
|
|
86
|
+
next Result.new(record: self, code: :invalid, errors: ["No changes supplied."]) if changes.empty?
|
|
87
|
+
|
|
88
|
+
errors = validate_update(changes)
|
|
89
|
+
next Result.new(record: self, code: :invalid, errors: errors) unless errors.empty?
|
|
90
|
+
|
|
91
|
+
now = Time.now.utc.iso8601
|
|
92
|
+
columns = self.class.update_fields.select { |name| changes.key?(name.to_sym) }
|
|
93
|
+
values = columns.map { |name| changes[name.to_sym] }
|
|
94
|
+
timestamps = self.class.columns.key?(:updated_at)
|
|
95
|
+
columns << "updated_at" if timestamps
|
|
96
|
+
values << now if timestamps
|
|
97
|
+
assignments = columns.each_with_index.map { |name, index| "#{name} = $#{index + 1}" }.join(", ")
|
|
98
|
+
returning = timestamps ? "updated_at" : "id"
|
|
99
|
+
sql = "UPDATE #{self.class.write_table_name} SET #{assignments} WHERE id = $#{values.length + 1} RETURNING #{returning}"
|
|
100
|
+
row = self.class.connection.execute(sql, values + [id]).hashes.first
|
|
101
|
+
next missing_result unless row
|
|
102
|
+
|
|
103
|
+
apply_changes(changes)
|
|
104
|
+
write_attribute(:updated_at, row["updated_at"]) if timestamps
|
|
105
|
+
Result.new(record: self)
|
|
106
|
+
rescue Hamster::Database::Error => error
|
|
107
|
+
self.class.database_failure(self, error)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def update!(**changes)
|
|
112
|
+
update(**changes).value!
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def destroy
|
|
116
|
+
Instrumentation.record(self.class.name, "destroy") do
|
|
117
|
+
next missing_result if id.nil? || destroyed?
|
|
118
|
+
|
|
119
|
+
sql = "DELETE FROM #{self.class.write_table_name} WHERE id = $1 RETURNING id"
|
|
120
|
+
row = self.class.connection.execute(sql, [id]).hashes.first
|
|
121
|
+
next missing_result unless row
|
|
122
|
+
|
|
123
|
+
@destroyed = true
|
|
124
|
+
Result.new(record: self)
|
|
125
|
+
rescue Hamster::Database::Error => error
|
|
126
|
+
self.class.database_failure(self, error)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def destroy!
|
|
131
|
+
destroy.value!
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def destroyed?
|
|
135
|
+
@destroyed == true
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def validate_update(_changes)
|
|
139
|
+
[]
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def apply_changes(changes)
|
|
143
|
+
changes.each { |name, value| write_attribute(name, value) }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
private
|
|
147
|
+
|
|
148
|
+
def missing_result
|
|
149
|
+
Result.new(record: self, code: :not_found, errors: ["Record not found."])
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hamster
|
|
4
|
+
# Named read-only projections; all execution remains in Relation.
|
|
5
|
+
class Query < KeyedRow
|
|
6
|
+
REGISTRY = []
|
|
7
|
+
|
|
8
|
+
def self.inherited(child)
|
|
9
|
+
child.set_definition(@base_source, fields, clauses)
|
|
10
|
+
REGISTRY << child
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.registry
|
|
14
|
+
REGISTRY.dup.freeze
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.validate_all!
|
|
18
|
+
registry.each { |query| query.all.compile }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.set_definition(source, definitions, operations)
|
|
22
|
+
@base_source = source
|
|
23
|
+
@fields = definitions.dup
|
|
24
|
+
@local_fields = []
|
|
25
|
+
@clauses = operations.dup
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.fields
|
|
29
|
+
Read.snapshot(@fields || {})
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.clauses
|
|
33
|
+
Read.snapshot(@clauses || [])
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.from(source)
|
|
37
|
+
@base_source = source.all.source
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.field(name, source:)
|
|
41
|
+
register_field(name, source)
|
|
42
|
+
# Spinel cannot expand a macro with a nonliteral source: expression.
|
|
43
|
+
# The manifest emits the equivalent static getters for compilation.
|
|
44
|
+
define_method("#{name}") { self[name] } if RUBY_ENGINE == "ruby"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.register_field(name, source)
|
|
48
|
+
key = Read.identifier(name).to_sym
|
|
49
|
+
unless key.to_s.match?(/\A[a-z_][a-z0-9_]*\z/)
|
|
50
|
+
raise Read::ValidationError, "#{self.name}: SELECT: field #{key} cannot be used as a result accessor"
|
|
51
|
+
end
|
|
52
|
+
if Query.method_defined?(key) || Query.private_method_defined?(key)
|
|
53
|
+
raise Read::ValidationError, "#{self.name}: SELECT: field #{key} would shadow a result method"
|
|
54
|
+
end
|
|
55
|
+
@local_fields ||= []
|
|
56
|
+
if @local_fields.include?(key)
|
|
57
|
+
raise Read::ValidationError, "#{self.name}: SELECT: duplicate field #{key}"
|
|
58
|
+
end
|
|
59
|
+
unless source.is_a?(Read::Node)
|
|
60
|
+
raise Read::ValidationError, "#{self.name}: SELECT: field #{key} source must be a SQL expression"
|
|
61
|
+
end
|
|
62
|
+
@local_fields = @local_fields + [key]
|
|
63
|
+
@fields = fields.merge(key => source)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.output(name); SQL.output(name); end
|
|
67
|
+
|
|
68
|
+
def self.add_clause(kind, args)
|
|
69
|
+
@clauses = clauses + [Read.snapshot([kind, args])]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.joins(source, on:); add_clause(:joins, [source, on]); end
|
|
73
|
+
def self.left_joins(source, on:); add_clause(:left_joins, [source, on]); end
|
|
74
|
+
def self.where(condition); add_clause(:where, [condition]); end
|
|
75
|
+
def self.group(*items); add_clause(:group, items); end
|
|
76
|
+
def self.having(condition); add_clause(:having, [condition]); end
|
|
77
|
+
def self.order(*items); add_clause(:order, items); end
|
|
78
|
+
def self.reorder(*items); add_clause(:reorder, items); end
|
|
79
|
+
def self.distinct(enabled = true); add_clause(:distinct, [enabled]); end
|
|
80
|
+
def self.limit(value); add_clause(:limit, [value]); end
|
|
81
|
+
def self.offset(value); add_clause(:offset, [value]); end
|
|
82
|
+
|
|
83
|
+
def self.all
|
|
84
|
+
raise Read::ValidationError, "#{name}: FROM: declare a base source with from" unless @base_source
|
|
85
|
+
raise Read::ValidationError, "#{name}: SELECT: declare at least one field" if fields.empty?
|
|
86
|
+
projection = fields.map { |key, expression| expression.as(key) }
|
|
87
|
+
relation = Relation.new(@base_source, { identity: name, select: projection, explicit: true,
|
|
88
|
+
result_class: self, declared_fields: projection })
|
|
89
|
+
clauses.each do |kind, args|
|
|
90
|
+
relation = case kind
|
|
91
|
+
when :joins then relation.joins(args[0], on: args[1])
|
|
92
|
+
when :left_joins then relation.left_joins(args[0], on: args[1])
|
|
93
|
+
when :where then relation.where(args[0])
|
|
94
|
+
when :group then relation.group(*args)
|
|
95
|
+
when :having then relation.having(args[0])
|
|
96
|
+
when :order then relation.order(*args)
|
|
97
|
+
when :reorder then relation.reorder(*args)
|
|
98
|
+
when :distinct then relation.distinct(args[0])
|
|
99
|
+
when :limit then relation.limit(args[0])
|
|
100
|
+
when :offset then relation.offset(args[0])
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
relation
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hamster
|
|
4
|
+
module Read
|
|
5
|
+
class Compiled
|
|
6
|
+
attr_reader :sql, :binds, :dependencies, :outputs, :row_class, :table_rows, :result_class
|
|
7
|
+
def initialize(sql, binds, validated, count: false)
|
|
8
|
+
@sql = sql.freeze
|
|
9
|
+
@binds = Read.snapshot(binds)
|
|
10
|
+
@dependencies = validated.dependencies
|
|
11
|
+
@outputs = count ? Read.snapshot([{ name: "count", type: :bigint, nullable: false }]) : validated.outputs
|
|
12
|
+
@row_class = validated.source.row_class
|
|
13
|
+
@result_class = count ? nil : validated.result_class
|
|
14
|
+
@table_rows = count ? false : validated.table_rows
|
|
15
|
+
freeze
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class Compiler
|
|
20
|
+
def initialize(validated)
|
|
21
|
+
@validated = validated
|
|
22
|
+
@binds = []
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def compile(count: false)
|
|
26
|
+
state = @validated.state
|
|
27
|
+
sql = state[:distinct] ? "SELECT DISTINCT " : "SELECT "
|
|
28
|
+
sql += state[:select].map { |node| expression(node) }.join(", ")
|
|
29
|
+
sql += " FROM #{source_sql(@validated.source)}"
|
|
30
|
+
state[:joins].each do |join|
|
|
31
|
+
kind = join.options[:kind] == :left ? "LEFT JOIN" : "INNER JOIN"
|
|
32
|
+
sql += " #{kind} #{source_sql(join.args[0])} ON #{expression(join.args[1])}"
|
|
33
|
+
end
|
|
34
|
+
sql += " WHERE " + state[:where].map { |node| expression(node) }.join(" AND ") unless state[:where].empty?
|
|
35
|
+
sql += " GROUP BY " + state[:group].map { |node| expression(node) }.join(", ") unless state[:group].empty?
|
|
36
|
+
sql += " HAVING " + state[:having].map { |node| expression(node) }.join(" AND ") unless state[:having].empty?
|
|
37
|
+
unless state[:order].empty?
|
|
38
|
+
sql += " ORDER BY " + state[:order].map { |node| "#{expression(node.args[0])} #{node.args[1].to_s.upcase}" }.join(", ")
|
|
39
|
+
end
|
|
40
|
+
sql += " LIMIT #{bind(state[:limit])}" unless state[:limit].nil?
|
|
41
|
+
sql += " OFFSET #{bind(state[:offset])}" unless state[:offset].nil?
|
|
42
|
+
sql = "SELECT COUNT(*) AS count FROM (#{sql}) AS hamster_count" if count
|
|
43
|
+
Compiled.new(sql, @binds, @validated, count: count)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def quote(name)
|
|
47
|
+
'"' + name.to_s.gsub('"', '""') + '"'
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def source_sql(source)
|
|
51
|
+
"#{quote(source.schema)}.#{quote(source.name)} AS #{quote(source.label)}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def bind(value)
|
|
55
|
+
@binds << value
|
|
56
|
+
"$#{@binds.length}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def expression(node)
|
|
60
|
+
case node.kind
|
|
61
|
+
when :column then "#{quote(node.args[0].label)}.#{quote(node.args[1])}"
|
|
62
|
+
when :value then bind(node.args[0])
|
|
63
|
+
when :as then "#{expression(node.args[0])} AS #{quote(node.args[1])}"
|
|
64
|
+
when :count
|
|
65
|
+
inner = node.args.empty? ? "*" : expression(node.args[0])
|
|
66
|
+
"COUNT(#{node.options[:distinct] ? 'DISTINCT ' : ''}#{inner})"
|
|
67
|
+
when :raw
|
|
68
|
+
"(" + node.args.map { |part| part.is_a?(String) ? part : bind(part.args[0]) }.join + ")"
|
|
69
|
+
when :and, :or
|
|
70
|
+
"(#{expression(node.args[0])} #{node.kind.to_s.upcase} #{expression(node.args[1])})"
|
|
71
|
+
when :not then "(NOT #{expression(node.args[0])})"
|
|
72
|
+
when :eq, :not_eq, :gt, :gte, :lt, :lte
|
|
73
|
+
left = expression(node.args[0])
|
|
74
|
+
right = node.args[1]
|
|
75
|
+
if right.kind == :value && right.args[0].nil?
|
|
76
|
+
"(#{left} IS #{node.kind == :not_eq ? 'NOT ' : ''}NULL)"
|
|
77
|
+
else
|
|
78
|
+
operator = { eq: "=", not_eq: "<>", gt: ">", gte: ">=", lt: "<", lte: "<=" }[node.kind]
|
|
79
|
+
"(#{left} #{operator} #{expression(right)})"
|
|
80
|
+
end
|
|
81
|
+
when :ilike
|
|
82
|
+
"(#{expression(node.args[0])} ILIKE #{expression(node.args[1])})"
|
|
83
|
+
when :in, :not_in
|
|
84
|
+
values = node.args[1]
|
|
85
|
+
return node.kind == :in ? "FALSE" : "TRUE" if values.empty?
|
|
86
|
+
non_null = values.reject { |value| value.nil? }
|
|
87
|
+
negative = node.kind == :not_in
|
|
88
|
+
pieces = []
|
|
89
|
+
unless non_null.empty?
|
|
90
|
+
left = expression(node.args[0])
|
|
91
|
+
pieces << "#{left} #{negative ? 'NOT IN' : 'IN'} (#{non_null.map { |value| bind(value) }.join(', ')})"
|
|
92
|
+
end
|
|
93
|
+
pieces << "#{expression(node.args[0])} IS #{negative ? 'NOT ' : ''}NULL" if values.include?(nil)
|
|
94
|
+
"(" + pieces.join(negative ? " AND " : " OR ") + ")"
|
|
95
|
+
else
|
|
96
|
+
raise ValidationError, "Unsupported compiled expression #{node.kind}"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Hamster
|
|
6
|
+
class KeyedRow
|
|
7
|
+
def initialize(attributes)
|
|
8
|
+
@attributes = Read.snapshot(attributes)
|
|
9
|
+
freeze
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def [](name)
|
|
13
|
+
fetch(name)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def fetch(name)
|
|
17
|
+
key = name.to_sym
|
|
18
|
+
raise MissingAttributeError, "Unloaded attribute #{key}" unless @attributes.key?(key)
|
|
19
|
+
@attributes[key]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def to_h
|
|
23
|
+
@attributes.dup
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
module Read
|
|
28
|
+
class Decoder
|
|
29
|
+
def initialize(compiled)
|
|
30
|
+
@compiled = compiled
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def decode(result)
|
|
34
|
+
expected = @compiled.outputs.map { |out| out[:name] }
|
|
35
|
+
unless result.columns == expected && result.columns.uniq.length == result.columns.length
|
|
36
|
+
raise DecodeError, "Result columns do not match the projection"
|
|
37
|
+
end
|
|
38
|
+
types = @compiled.outputs.map { |out| TYPES[out[:type]] }
|
|
39
|
+
raise DecodeError, "Result PostgreSQL type IDs do not match the projection" unless result.types == types
|
|
40
|
+
row_class = @compiled.row_class
|
|
41
|
+
result.rows.map do |cells|
|
|
42
|
+
raise DecodeError, "Result row width does not match the projection" unless cells.length == expected.length
|
|
43
|
+
attributes = {}
|
|
44
|
+
@compiled.outputs.each_with_index do |out, index|
|
|
45
|
+
attributes[out[:name].to_sym] = cell(cells[index], out)
|
|
46
|
+
end
|
|
47
|
+
if @compiled.result_class
|
|
48
|
+
@compiled.result_class.new(attributes)
|
|
49
|
+
elsif @compiled.table_rows
|
|
50
|
+
row_class.new(attributes)
|
|
51
|
+
else
|
|
52
|
+
KeyedRow.new(attributes)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def cell(value, output)
|
|
58
|
+
if value.nil?
|
|
59
|
+
raise DecodeError, "Unexpected NULL for #{output[:name]}" unless output[:nullable]
|
|
60
|
+
return nil
|
|
61
|
+
end
|
|
62
|
+
raise DecodeError, "Expected raw text cell for #{output[:name]}" unless value.is_a?(String)
|
|
63
|
+
case output[:type]
|
|
64
|
+
when :text then value.dup
|
|
65
|
+
when :integer, :bigint
|
|
66
|
+
raise DecodeError, "Malformed integer for #{output[:name]}" unless value.match?(/\A-?[0-9]+\z/)
|
|
67
|
+
begin
|
|
68
|
+
number = value.to_i
|
|
69
|
+
rescue RangeError
|
|
70
|
+
raise DecodeError, "Integer out of range for #{output[:name]}"
|
|
71
|
+
end
|
|
72
|
+
low, high = output[:type] == :integer ? [-2147483648, 2147483647] : [-9223372036854775808, 9223372036854775807]
|
|
73
|
+
raise DecodeError, "Integer out of range for #{output[:name]}" unless number >= low && number <= high
|
|
74
|
+
number
|
|
75
|
+
when :boolean
|
|
76
|
+
return true if value == "t"
|
|
77
|
+
return false if value == "f"
|
|
78
|
+
raise DecodeError, "Malformed boolean for #{output[:name]}"
|
|
79
|
+
when :timestamptz
|
|
80
|
+
# PostgreSQL ISO DateStyle uses a space and may emit an hour-only offset.
|
|
81
|
+
unless value.match?(/\A[0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?[+-][0-9]{2}(:[0-9]{2})?\z/)
|
|
82
|
+
raise DecodeError, "Malformed timestamp for #{output[:name]}"
|
|
83
|
+
end
|
|
84
|
+
normalized = value.sub(" ", "T")
|
|
85
|
+
normalized += ":00" if normalized.match?(/[+-][0-9]{2}\z/)
|
|
86
|
+
begin
|
|
87
|
+
timestamp = Time.iso8601(normalized)
|
|
88
|
+
unless timestamp.strftime("%Y-%m-%dT%H:%M:%S") == normalized[0, 19]
|
|
89
|
+
raise DecodeError, "Malformed timestamp for #{output[:name]}"
|
|
90
|
+
end
|
|
91
|
+
timestamp
|
|
92
|
+
rescue ArgumentError
|
|
93
|
+
raise DecodeError, "Malformed timestamp for #{output[:name]}"
|
|
94
|
+
end
|
|
95
|
+
else
|
|
96
|
+
raise DecodeError, "Unsupported output type"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|