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.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +61 -0
  4. data/exe/hamster +24 -0
  5. data/lib/hamster/build/manifest.rb +53 -0
  6. data/lib/hamster/build/postgres_compatibility.rb +38 -0
  7. data/lib/hamster/build/query_accessors.rb +73 -0
  8. data/lib/hamster/build/view_compiler.rb +239 -0
  9. data/lib/hamster/cli.rb +166 -0
  10. data/lib/hamster/console.rb +34 -0
  11. data/lib/hamster/data/persistence.rb +152 -0
  12. data/lib/hamster/data/query.rb +106 -0
  13. data/lib/hamster/data/read/compiler.rb +101 -0
  14. data/lib/hamster/data/read/decoder.rb +101 -0
  15. data/lib/hamster/data/read/nodes.rb +118 -0
  16. data/lib/hamster/data/read/relation.rb +198 -0
  17. data/lib/hamster/data/read/schema.rb +40 -0
  18. data/lib/hamster/data/read/validator.rb +273 -0
  19. data/lib/hamster/data/record.rb +81 -0
  20. data/lib/hamster/data/table.rb +90 -0
  21. data/lib/hamster/data.rb +21 -0
  22. data/lib/hamster/database/admin.rb +39 -0
  23. data/lib/hamster/database/configuration.rb +84 -0
  24. data/lib/hamster/database/connection.rb +168 -0
  25. data/lib/hamster/database/database.rb +92 -0
  26. data/lib/hamster/database/errors.rb +21 -0
  27. data/lib/hamster/database/pool.rb +98 -0
  28. data/lib/hamster/database/postgres.rb +66 -0
  29. data/lib/hamster/database/result.rb +30 -0
  30. data/lib/hamster/schema_validation.rb +12 -0
  31. data/lib/hamster/web/action/base.rb +39 -0
  32. data/lib/hamster/web/application.rb +23 -0
  33. data/lib/hamster/web/context.rb +25 -0
  34. data/lib/hamster/web/html.rb +80 -0
  35. data/lib/hamster/web/http.rb +34 -0
  36. data/lib/hamster/web/instrumentation.rb +28 -0
  37. data/lib/hamster/web/router.rb +27 -0
  38. data/lib/hamster/web/routing/base.rb +131 -0
  39. data/lib/hamster/web/version.rb +7 -0
  40. data/lib/hamster/web/view/form.rb +119 -0
  41. data/lib/hamster/web/view.rb +196 -0
  42. data/lib/hamster/web.rb +13 -0
  43. data/templates/app/.gitignore +5 -0
  44. data/templates/app/AGENTS.md +8 -0
  45. data/templates/app/Gemfile +3 -0
  46. data/templates/app/README.md +32 -0
  47. data/templates/app/app/routes/home/index/action.rb +14 -0
  48. data/templates/app/app/routes/home/index/view.html.erb +2 -0
  49. data/templates/app/app/routes/mappings.rb +8 -0
  50. data/templates/app/app/views/layouts/application.html.erb +3 -0
  51. data/templates/app/bin/hamster +4 -0
  52. data/templates/app/bin/server +8 -0
  53. data/templates/app/config/application.rb +23 -0
  54. data/templates/app/config/commands.rb +16 -0
  55. data/templates/app/config/database.rb +20 -0
  56. data/templates/app/config/hamster.json +1 -0
  57. data/templates/app/db/migrations/.keep +0 -0
  58. data/templates/app/spin.lock +6 -0
  59. data/templates/app/spin.toml +2 -0
  60. metadata +133 -0
@@ -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