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,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hamster
|
|
4
|
+
module Read
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
class ValidationError < Error; end
|
|
7
|
+
class DecodeError < Error; end
|
|
8
|
+
|
|
9
|
+
TYPES = { integer: 23, bigint: 20, text: 25, boolean: 16, timestamptz: 1184 }.freeze
|
|
10
|
+
|
|
11
|
+
def self.snapshot(value)
|
|
12
|
+
case value
|
|
13
|
+
when Array then value.map { |item| snapshot(item) }.freeze
|
|
14
|
+
when Hash
|
|
15
|
+
result = {}
|
|
16
|
+
value.each { |key, item| result[snapshot(key)] = snapshot(item) }
|
|
17
|
+
result.freeze
|
|
18
|
+
when String, Time then value.dup.freeze
|
|
19
|
+
else value
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.structural(value)
|
|
24
|
+
case value
|
|
25
|
+
when Node, Source then value.key
|
|
26
|
+
when Array then value.map { |item| structural(item) }
|
|
27
|
+
when Hash
|
|
28
|
+
result = {}
|
|
29
|
+
value.each { |key, item| result[key] = structural(item) }
|
|
30
|
+
result
|
|
31
|
+
else value
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.identifier(value)
|
|
36
|
+
name = value.to_s
|
|
37
|
+
raise ValidationError, "Invalid identifier #{name.inspect}" if name.empty? || name.include?("\0")
|
|
38
|
+
name.dup.freeze
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
class Source
|
|
42
|
+
attr_reader :row_class, :name, :schema, :label, :columns
|
|
43
|
+
def initialize(row_class, label = nil)
|
|
44
|
+
@row_class = row_class
|
|
45
|
+
@name = row_class.table_name
|
|
46
|
+
@schema = row_class.schema_name
|
|
47
|
+
@label = Read.identifier(label || @name)
|
|
48
|
+
@columns = Read.snapshot(row_class.columns)
|
|
49
|
+
freeze
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def [](name)
|
|
53
|
+
Node.new(:column, [self, name.to_sym])
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def all
|
|
57
|
+
Relation.new(self)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def key
|
|
61
|
+
[row_class, schema, name, label]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# One expression representation is shared by validation, compilation and decoding.
|
|
66
|
+
class Node
|
|
67
|
+
attr_reader :kind, :args, :options
|
|
68
|
+
def initialize(kind, args = [], options = {})
|
|
69
|
+
@kind = kind
|
|
70
|
+
@args = Read.snapshot(args)
|
|
71
|
+
@options = Read.snapshot(options)
|
|
72
|
+
freeze
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def key
|
|
76
|
+
[kind, Read.structural(args), Read.structural(options)]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def eq(value); compare(:eq, value); end
|
|
80
|
+
def not_eq(value); compare(:not_eq, value); end
|
|
81
|
+
def gt(value); compare(:gt, value); end
|
|
82
|
+
def gte(value); compare(:gte, value); end
|
|
83
|
+
def lt(value); compare(:lt, value); end
|
|
84
|
+
def lte(value); compare(:lte, value); end
|
|
85
|
+
def ilike(pattern); compare(:ilike, pattern); end
|
|
86
|
+
def in(values); Node.new(:in, [self, values]); end
|
|
87
|
+
def not_in(values); Node.new(:not_in, [self, values]); end
|
|
88
|
+
def as(name); Node.new(:as, [self, Read.identifier(name)]); end
|
|
89
|
+
def asc; Node.new(:order, [self, :asc]); end
|
|
90
|
+
def desc; Node.new(:order, [self, :desc]); end
|
|
91
|
+
def and(other); Node.new(:and, [self, other]); end
|
|
92
|
+
def or(other); Node.new(:or, [self, other]); end
|
|
93
|
+
def not; Node.new(:not, [self]); end
|
|
94
|
+
|
|
95
|
+
def compare(operator, value)
|
|
96
|
+
Node.new(operator, [self, value.is_a?(Node) ? value : Node.new(:value, [value])])
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
module SQL
|
|
102
|
+
def self.count(column = nil, distinct: false)
|
|
103
|
+
Read::Node.new(:count, column.nil? ? [] : [column], { distinct: distinct })
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def self.output(name)
|
|
107
|
+
Read::Node.new(:output, [Read.identifier(name)])
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def self.bind(value)
|
|
111
|
+
Read::Node.new(:value, [value])
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def self.raw(parts, dependencies:, type:, nullable:, aggregate: false)
|
|
115
|
+
Read::Node.new(:raw, parts, { dependencies: dependencies, type: type, nullable: nullable, aggregate: aggregate })
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hamster
|
|
4
|
+
class Relation
|
|
5
|
+
attr_reader :source, :state
|
|
6
|
+
|
|
7
|
+
def initialize(source, state = {})
|
|
8
|
+
@source = source
|
|
9
|
+
@state = Read.snapshot({ select: [], explicit: false, joins: [], where: [], group: [],
|
|
10
|
+
having: [], order: [], distinct: false, limit: nil, offset: nil, page: nil,
|
|
11
|
+
identity: "anonymous" }.merge(state))
|
|
12
|
+
freeze
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def all; self; end
|
|
16
|
+
def named(identity); change(identity: identity.to_s); end
|
|
17
|
+
def select(*items); change(select: state[:select] + expressions(items), explicit: true); end
|
|
18
|
+
def reselect(*items); change(select: expressions(items), explicit: true); end
|
|
19
|
+
def where(condition); change(where: state[:where] + conditions(condition)); end
|
|
20
|
+
def having(condition); change(having: state[:having] + conditions(condition)); end
|
|
21
|
+
def group(*items); change(group: state[:group] + expressions(items)); end
|
|
22
|
+
def order(*items); change(order: state[:order] + orderings(items)); end
|
|
23
|
+
def reorder(*items); change(order: orderings(items)); end
|
|
24
|
+
def distinct(enabled = true); change(distinct: enabled); end
|
|
25
|
+
def limit(value); change(limit: value); end
|
|
26
|
+
def offset(value); change(offset: value, page: nil); end
|
|
27
|
+
|
|
28
|
+
def page(number)
|
|
29
|
+
unless number.is_a?(Integer) && number > 0
|
|
30
|
+
raise Read::ValidationError, "#{state[:identity]}: PAGE: use a positive integer starting at 1"
|
|
31
|
+
end
|
|
32
|
+
change(page: number, offset: nil)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def joins(other, on:)
|
|
36
|
+
join(other, on, :inner)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def left_joins(other, on:)
|
|
40
|
+
join(other, on, :left)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def compile
|
|
44
|
+
Read::Compiler.new(Read::Validator.new(resolve_page).validate).compile
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def to_sql; compile.sql; end
|
|
48
|
+
def binds; compile.binds; end
|
|
49
|
+
|
|
50
|
+
def load(connection: nil)
|
|
51
|
+
Instrumentation.instrument("query.load", attributes: {"query.name" => state[:identity]}) do
|
|
52
|
+
compiled = compile
|
|
53
|
+
result = (connection || Record.connection).execute(compiled.sql, compiled.binds)
|
|
54
|
+
Read::Decoder.new(compiled).decode(result)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def count(connection: nil)
|
|
59
|
+
Instrumentation.instrument("query.count", attributes: {"query.name" => state[:identity]}) do
|
|
60
|
+
compiled = Read::Compiler.new(Read::Validator.new(resolve_page).validate).compile(count: true)
|
|
61
|
+
result = (connection || Record.connection).execute(compiled.sql, compiled.binds)
|
|
62
|
+
rows = Read::Decoder.new(compiled).decode(result)
|
|
63
|
+
raise Read::DecodeError, "Count result must contain exactly one row" unless rows.length == 1
|
|
64
|
+
rows.first[:count]
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Terminal reads stay lazy until this call and use PostgreSQL for the limit.
|
|
69
|
+
def first(connection: nil)
|
|
70
|
+
return resolve_page.first(connection: connection) unless state[:page].nil?
|
|
71
|
+
compile
|
|
72
|
+
return nil if state[:limit] == 0
|
|
73
|
+
|
|
74
|
+
query = self
|
|
75
|
+
if query.state[:order].empty? && implicit_primary_order?
|
|
76
|
+
query = query.order(source[:id].asc)
|
|
77
|
+
end
|
|
78
|
+
query.limit(query.state[:limit].nil? ? 1 : [query.state[:limit], 1].min)
|
|
79
|
+
.load(connection: connection).first
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def last(connection: nil)
|
|
83
|
+
return resolve_page.last(connection: connection) unless state[:page].nil?
|
|
84
|
+
compile
|
|
85
|
+
return nil if state[:limit] == 0
|
|
86
|
+
|
|
87
|
+
# Reversing an ordered, offset relation changes which rows the offset skips.
|
|
88
|
+
# Load that already-scoped window to preserve its meaning.
|
|
89
|
+
if !state[:offset].nil? || (state[:order].empty? && !implicit_primary_order?)
|
|
90
|
+
query = state[:order].empty? && implicit_primary_order? ? order(source[:id].asc) : self
|
|
91
|
+
return query.load(connection: connection).last
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
if state[:order].empty? && !state[:limit].nil?
|
|
95
|
+
return order(source[:id].asc).load(connection: connection).last
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
reversed = if state[:order].empty?
|
|
99
|
+
order(source[:id].desc)
|
|
100
|
+
else
|
|
101
|
+
order = state[:order].map do |item|
|
|
102
|
+
direction = item.args[1] == :asc ? :desc : :asc
|
|
103
|
+
Read::Node.new(:order, [item.args[0], direction])
|
|
104
|
+
end
|
|
105
|
+
reorder(*order)
|
|
106
|
+
end
|
|
107
|
+
if reversed.state[:limit].nil?
|
|
108
|
+
reversed.limit(1).load(connection: connection).first
|
|
109
|
+
else
|
|
110
|
+
reversed.load(connection: connection).first
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def validate_page_size!
|
|
117
|
+
unless state[:limit].is_a?(Integer) && state[:limit] > 0
|
|
118
|
+
raise Read::ValidationError, "#{state[:identity]}: PAGE: pagination requires a positive page size; add .limit(size)"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def resolve_page
|
|
123
|
+
return self if state[:page].nil?
|
|
124
|
+
validate_page_size!
|
|
125
|
+
query = self
|
|
126
|
+
if state[:order].empty?
|
|
127
|
+
unless implicit_primary_order?
|
|
128
|
+
raise Read::ValidationError, "#{state[:identity]}: PAGE: add an explicit order for grouped, distinct, aggregate, or id-less queries"
|
|
129
|
+
end
|
|
130
|
+
query = order(source[:id].asc)
|
|
131
|
+
end
|
|
132
|
+
query.offset((state[:page] - 1) * state[:limit])
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def change(changes)
|
|
136
|
+
Relation.new(source, state.merge(changes))
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def expression(item)
|
|
140
|
+
item.is_a?(Symbol) ? source[item] : item
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def expressions(items)
|
|
144
|
+
items.flatten.map { |item| expression(item) }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def conditions(condition)
|
|
148
|
+
if condition.is_a?(Hash)
|
|
149
|
+
condition.flat_map do |key, value|
|
|
150
|
+
column = source[key]
|
|
151
|
+
if value.is_a?(Range)
|
|
152
|
+
bounds = []
|
|
153
|
+
bounds << column.gte(value.begin) unless value.begin.nil?
|
|
154
|
+
if !value.end.nil?
|
|
155
|
+
bounds << (value.exclude_end? ? column.lt(value.end) : column.lte(value.end))
|
|
156
|
+
end
|
|
157
|
+
bounds
|
|
158
|
+
elsif value.is_a?(Array)
|
|
159
|
+
[column.in(value)]
|
|
160
|
+
else
|
|
161
|
+
[column.eq(value)]
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
else
|
|
165
|
+
[condition]
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def orderings(items)
|
|
170
|
+
items.flatten.flat_map do |item|
|
|
171
|
+
if item.is_a?(Hash)
|
|
172
|
+
item.map { |key, direction| Read::Node.new(:order, [source[key], direction]) }
|
|
173
|
+
else
|
|
174
|
+
node = expression(item)
|
|
175
|
+
[node.is_a?(Read::Node) && node.kind == :order ? node : Read::Node.new(:order, [node, :asc])]
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def implicit_primary_order?
|
|
181
|
+
id = source.columns[:id]
|
|
182
|
+
id && id[:enabled] && state[:group].empty? && state[:having].empty? && !state[:distinct] &&
|
|
183
|
+
!state[:select].any? { |item| aggregate_expression?(item) } &&
|
|
184
|
+
!state[:order].any? { |item| aggregate_expression?(item) }
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def aggregate_expression?(item)
|
|
188
|
+
return false unless item.is_a?(Read::Node)
|
|
189
|
+
return true if item.kind == :count || (item.kind == :raw && item.options[:aggregate])
|
|
190
|
+
item.args.any? { |argument| aggregate_expression?(argument) }
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def join(other, on, kind)
|
|
194
|
+
joined = other.is_a?(Read::Source) ? other : other.all.source
|
|
195
|
+
change(joins: state[:joins] + [Read::Node.new(:join, [joined, on], { kind: kind })])
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hamster
|
|
4
|
+
module Read
|
|
5
|
+
class Schema
|
|
6
|
+
def self.validate(connection, tables = Table.registry)
|
|
7
|
+
errors = []
|
|
8
|
+
tables.each do |table|
|
|
9
|
+
catalog = connection.execute(<<~SQL, [table.schema_name, table.table_name]).hashes
|
|
10
|
+
SELECT a.attname AS name, a.atttypid::text AS type_id,
|
|
11
|
+
a.attnotnull::text AS not_null
|
|
12
|
+
FROM pg_catalog.pg_attribute a
|
|
13
|
+
JOIN pg_catalog.pg_class c ON c.oid = a.attrelid
|
|
14
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
15
|
+
WHERE n.nspname = $1 AND c.relname = $2
|
|
16
|
+
AND c.relkind IN ('r', 'p', 'v', 'm', 'f')
|
|
17
|
+
AND a.attnum > 0 AND NOT a.attisdropped
|
|
18
|
+
SQL
|
|
19
|
+
prefix = "#{table.schema_name}.#{table.table_name}"
|
|
20
|
+
errors << "#{prefix}: table is missing" if catalog.empty?
|
|
21
|
+
table.columns.each do |name, definition|
|
|
22
|
+
next unless definition[:enabled]
|
|
23
|
+
actual = catalog.find { |row| row["name"] == name.to_s }
|
|
24
|
+
if actual.nil?
|
|
25
|
+
errors << "#{prefix}.#{name}: column is missing"
|
|
26
|
+
else
|
|
27
|
+
unless actual["type_id"] == TYPES[definition[:type]].to_s
|
|
28
|
+
errors << "#{prefix}.#{name}: incompatible type (expected #{definition[:type]})"
|
|
29
|
+
end
|
|
30
|
+
if !definition[:nullable] && actual["not_null"] != "true"
|
|
31
|
+
errors << "#{prefix}.#{name}: database permits NULL"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
errors
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
module Hamster
|
|
5
|
+
module Read
|
|
6
|
+
class Validator
|
|
7
|
+
attr_reader :relation, :source, :state, :outputs, :dependencies, :table_rows, :result_class
|
|
8
|
+
|
|
9
|
+
def initialize(relation)
|
|
10
|
+
@relation = relation
|
|
11
|
+
@source = relation.source
|
|
12
|
+
@state = relation.state.dup
|
|
13
|
+
@outputs = []
|
|
14
|
+
@dependencies = []
|
|
15
|
+
@sources = [source]
|
|
16
|
+
@left_sources = []
|
|
17
|
+
@aliases = {}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def fail!(message, clause)
|
|
21
|
+
raise ValidationError, "#{state[:identity]}: #{clause}: #{message}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def validate
|
|
25
|
+
state[:joins].each do |join|
|
|
26
|
+
other = join.args[0]
|
|
27
|
+
fail!("duplicate source alias #{other.label}", "JOIN") if @sources.any? { |s| s.label == other.label }
|
|
28
|
+
@sources << other
|
|
29
|
+
@left_sources << other.key if join.options[:kind] == :left
|
|
30
|
+
end
|
|
31
|
+
selections = if state[:explicit]
|
|
32
|
+
state[:select]
|
|
33
|
+
else
|
|
34
|
+
source.columns.select { |_key, spec| spec[:enabled] }.map { |key, _spec| source[key] }
|
|
35
|
+
end
|
|
36
|
+
fail!("empty projection", "SELECT") if selections.empty?
|
|
37
|
+
(state[:declared_fields] || []).each { |node| @aliases[node.args[1]] = node.args[0] }
|
|
38
|
+
declared_aliases = @aliases.dup
|
|
39
|
+
declared_projection = (state[:declared_fields] || []).map do |node|
|
|
40
|
+
resolved = resolve(node, "SELECT", [])
|
|
41
|
+
[output_name(resolved), unwrap(resolved).key]
|
|
42
|
+
end
|
|
43
|
+
selections.each do |node|
|
|
44
|
+
if node.is_a?(Node) && node.kind == :as
|
|
45
|
+
name = node.args[1]
|
|
46
|
+
# Query output references continue to name the declared field even
|
|
47
|
+
# when a runtime projection replaces or aliases that field.
|
|
48
|
+
@aliases[name] = node.args[0] unless declared_aliases.key?(name)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
state[:select] = unique(selections.map do |node|
|
|
52
|
+
resolved = resolve(node, "SELECT", [])
|
|
53
|
+
node.kind == :output ? resolved.as(node.args[0]) : resolved
|
|
54
|
+
end)
|
|
55
|
+
state[:group] = unique(state[:group].map { |node| resolve(node, "GROUP BY", []) })
|
|
56
|
+
[:where, :having, :order].each do |clause|
|
|
57
|
+
state[clause] = state[clause].map { |node| resolve(node, clause.to_s.upcase, []) }
|
|
58
|
+
end
|
|
59
|
+
visible = [source]
|
|
60
|
+
state[:joins] = state[:joins].map do |join|
|
|
61
|
+
visible << join.args[0]
|
|
62
|
+
node = resolve(join.args[1], "ON", [])
|
|
63
|
+
info = inspect_node(node, "ON", visible)
|
|
64
|
+
predicate!(info, "ON")
|
|
65
|
+
Node.new(:join, [join.args[0], node], join.options)
|
|
66
|
+
end
|
|
67
|
+
state[:select].each do |node|
|
|
68
|
+
info = inspect_node(node, "SELECT", @sources)
|
|
69
|
+
name = output_name(node)
|
|
70
|
+
fail!("projection needs an explicit alias", "SELECT") unless name
|
|
71
|
+
fail!("conflicting output #{name}", "SELECT") if outputs.any? { |out| out[:name] == name }
|
|
72
|
+
outputs << { name: name, type: info[:type], nullable: info[:nullable], expression: unwrap(node) }.freeze
|
|
73
|
+
end
|
|
74
|
+
[:where, :having].each do |clause|
|
|
75
|
+
state[clause].each { |node| predicate!(inspect_node(node, clause.to_s.upcase, @sources), clause.to_s.upcase) }
|
|
76
|
+
end
|
|
77
|
+
state[:group].each { |node| inspect_node(node, "GROUP BY", @sources) }
|
|
78
|
+
state[:order].each do |node|
|
|
79
|
+
fail!("unsupported ordering", "ORDER BY") unless node.is_a?(Node) && node.kind == :order
|
|
80
|
+
fail!("direction must be :asc or :desc", "ORDER BY") unless [:asc, :desc].include?(node.args[1])
|
|
81
|
+
inspect_node(node.args[0], "ORDER BY", @sources)
|
|
82
|
+
end
|
|
83
|
+
grouped = !state[:group].empty? || !state[:having].empty? ||
|
|
84
|
+
state[:select].any? { |node| aggregate?(node) } || state[:order].any? { |node| aggregate?(node.args[0]) }
|
|
85
|
+
if grouped
|
|
86
|
+
state[:select].each { |node| check_group(unwrap(node), "SELECT") }
|
|
87
|
+
state[:having].each { |node| check_group(node, "HAVING") }
|
|
88
|
+
state[:order].each { |node| check_group(node.args[0], "ORDER BY") }
|
|
89
|
+
end
|
|
90
|
+
fail!("distinct must be boolean", "DISTINCT") unless [true, false].include?(state[:distinct])
|
|
91
|
+
if state[:distinct]
|
|
92
|
+
state[:order].each do |node|
|
|
93
|
+
unless outputs.any? { |out| out[:expression].key == node.args[0].key }
|
|
94
|
+
fail!("DISTINCT ordering must be selected", "ORDER BY")
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
[:limit, :offset].each do |clause|
|
|
99
|
+
value = state[clause]
|
|
100
|
+
unless value.nil? || (value.is_a?(Integer) && value >= 0)
|
|
101
|
+
fail!("must be a nonnegative integer", clause.to_s.upcase)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
@table_rows = state[:joins].empty? && !grouped && state[:select].all? do |node|
|
|
105
|
+
node.kind == :column && node.args[0].key == source.key
|
|
106
|
+
end
|
|
107
|
+
if state[:result_class]
|
|
108
|
+
@result_class = state[:result_class] if outputs.all? do |out|
|
|
109
|
+
declared_projection.include?([out[:name], out[:expression].key])
|
|
110
|
+
end
|
|
111
|
+
@table_rows = false
|
|
112
|
+
end
|
|
113
|
+
@state = Read.snapshot(state)
|
|
114
|
+
@outputs.freeze
|
|
115
|
+
@dependencies.freeze
|
|
116
|
+
self
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def resolve(node, clause, stack)
|
|
120
|
+
fail!("unsupported expression #{node.class}", clause) unless node.is_a?(Node)
|
|
121
|
+
if node.kind == :output
|
|
122
|
+
name = node.args[0]
|
|
123
|
+
fail!("unknown output #{name}", clause) unless @aliases.key?(name)
|
|
124
|
+
fail!("cyclic output #{name}", clause) if stack.include?(name)
|
|
125
|
+
return resolve(@aliases[name], clause, stack + [name])
|
|
126
|
+
end
|
|
127
|
+
args = node.args.map { |arg| arg.is_a?(Node) ? resolve(arg, clause, stack) : arg }
|
|
128
|
+
Node.new(node.kind, args, node.options)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def unique(nodes)
|
|
132
|
+
result = []
|
|
133
|
+
nodes.each { |node| result << node unless result.any? { |old| old.key == node.key } }
|
|
134
|
+
result
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def unwrap(node)
|
|
138
|
+
node.kind == :as ? node.args[0] : node
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def output_name(node)
|
|
142
|
+
return node.args[1] if node.kind == :as
|
|
143
|
+
return node.args[1].to_s if node.kind == :column
|
|
144
|
+
return "count" if node.kind == :count
|
|
145
|
+
nil
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def info(type, nullable, aggregate = false)
|
|
149
|
+
{ type: type, nullable: nullable, aggregate: aggregate }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def predicate!(metadata, clause)
|
|
153
|
+
fail!("condition must be boolean", clause) unless metadata[:type] == :boolean
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def inspect_node(node, clause, visible)
|
|
157
|
+
fail!("unsupported expression", clause) unless node.is_a?(Node)
|
|
158
|
+
case node.kind
|
|
159
|
+
when :column
|
|
160
|
+
table, name = node.args
|
|
161
|
+
fail!("source #{table.label} is not visible", clause) unless visible.any? { |s| s.key == table.key }
|
|
162
|
+
definition = table.columns[name]
|
|
163
|
+
fail!("#{table.schema}.#{table.name}.#{name} is undeclared", clause) unless definition
|
|
164
|
+
fail!("#{table.schema}.#{table.name}.#{name} is disabled", clause) unless definition[:enabled]
|
|
165
|
+
dependency = { schema: table.schema, table: table.name, source: table.label, column: name, clause: clause }.freeze
|
|
166
|
+
@dependencies << dependency unless @dependencies.include?(dependency)
|
|
167
|
+
info(definition[:type], definition[:nullable] || @left_sources.include?(table.key))
|
|
168
|
+
when :as
|
|
169
|
+
fail!("projection aliases are only allowed in SELECT", clause) unless clause == "SELECT"
|
|
170
|
+
inspect_node(node.args[0], clause, visible)
|
|
171
|
+
when :value
|
|
172
|
+
value = node.args[0]
|
|
173
|
+
type = case value
|
|
174
|
+
when NilClass then :null
|
|
175
|
+
when Integer then :bigint
|
|
176
|
+
when String then :text
|
|
177
|
+
when TrueClass, FalseClass then :boolean
|
|
178
|
+
when Time then :timestamptz
|
|
179
|
+
else
|
|
180
|
+
case value.class.to_s
|
|
181
|
+
when "DateTime" then :timestamptz
|
|
182
|
+
when "Date" then :date
|
|
183
|
+
else fail!("unsupported bound value #{value.class}", clause)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
info(type, value.nil?)
|
|
187
|
+
when :count
|
|
188
|
+
fail!("aggregate is not allowed", clause) if ["WHERE", "ON", "GROUP BY"].include?(clause)
|
|
189
|
+
fail!("COUNT DISTINCT requires a column", clause) if node.args.empty? && node.options[:distinct]
|
|
190
|
+
fail!("COUNT distinct flag must be boolean", clause) unless [true, false].include?(node.options[:distinct])
|
|
191
|
+
node.args.each do |arg|
|
|
192
|
+
fail!("nested aggregate", clause) if aggregate?(arg)
|
|
193
|
+
inspect_node(arg, clause, visible)
|
|
194
|
+
end
|
|
195
|
+
info(:bigint, false, true)
|
|
196
|
+
when :raw
|
|
197
|
+
type = node.options[:type]
|
|
198
|
+
fail!("unsupported raw type", clause) unless TYPES.key?(type)
|
|
199
|
+
[:nullable, :aggregate].each do |flag|
|
|
200
|
+
fail!("raw #{flag} must be boolean", clause) unless [true, false].include?(node.options[flag])
|
|
201
|
+
end
|
|
202
|
+
if node.options[:aggregate] && ["WHERE", "ON", "GROUP BY"].include?(clause)
|
|
203
|
+
fail!("aggregate is not allowed", clause)
|
|
204
|
+
end
|
|
205
|
+
deps = node.options[:dependencies]
|
|
206
|
+
fail!("raw dependencies must be an array of columns", clause) unless deps.is_a?(Array)
|
|
207
|
+
deps.each do |dep|
|
|
208
|
+
fail!("raw dependency must be a column", clause) unless dep.is_a?(Node) && dep.kind == :column
|
|
209
|
+
inspect_node(dep, clause, visible)
|
|
210
|
+
end
|
|
211
|
+
fail!("raw parts cannot be empty", clause) if node.args.empty?
|
|
212
|
+
node.args.each do |part|
|
|
213
|
+
unless part.is_a?(String) || (part.is_a?(Node) && part.kind == :value)
|
|
214
|
+
fail!("raw parts must be strings or SQL.bind nodes", clause)
|
|
215
|
+
end
|
|
216
|
+
inspect_node(part, clause, visible) if part.is_a?(Node)
|
|
217
|
+
end
|
|
218
|
+
info(type, node.options[:nullable], node.options[:aggregate])
|
|
219
|
+
when :eq, :not_eq, :gt, :gte, :lt, :lte
|
|
220
|
+
left = inspect_node(node.args[0], clause, visible)
|
|
221
|
+
right = inspect_node(node.args[1], clause, visible)
|
|
222
|
+
if right[:type] == :null && ![:eq, :not_eq].include?(node.kind)
|
|
223
|
+
fail!("NULL only supports eq and not_eq", clause)
|
|
224
|
+
end
|
|
225
|
+
compatible!(left, right, node.args[1], clause)
|
|
226
|
+
info(:boolean, right[:type] == :null ? false : left[:nullable] || right[:nullable])
|
|
227
|
+
when :ilike
|
|
228
|
+
left = inspect_node(node.args[0], clause, visible)
|
|
229
|
+
right = inspect_node(node.args[1], clause, visible)
|
|
230
|
+
fail!("ILIKE requires text operands", clause) unless left[:type] == :text && right[:type] == :text
|
|
231
|
+
info(:boolean, left[:nullable] || right[:nullable])
|
|
232
|
+
when :in, :not_in
|
|
233
|
+
left = inspect_node(node.args[0], clause, visible)
|
|
234
|
+
values = node.args[1]
|
|
235
|
+
fail!("membership requires an array", clause) unless values.is_a?(Array)
|
|
236
|
+
values.each do |value|
|
|
237
|
+
rhs = Node.new(:value, [value])
|
|
238
|
+
compatible!(left, inspect_node(rhs, clause, visible), rhs, clause)
|
|
239
|
+
end
|
|
240
|
+
info(:boolean, left[:nullable])
|
|
241
|
+
when :and, :or, :not
|
|
242
|
+
node.args.each { |arg| predicate!(inspect_node(arg, clause, visible), clause) }
|
|
243
|
+
info(:boolean, true)
|
|
244
|
+
else
|
|
245
|
+
fail!("unsupported expression #{node.kind}", clause)
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def compatible!(left, right, rhs, clause)
|
|
250
|
+
return if right[:type] == :null || left[:type] == right[:type]
|
|
251
|
+
return if [:integer, :bigint].include?(left[:type]) && [:integer, :bigint].include?(right[:type])
|
|
252
|
+
# Bound text is passed with an unspecified PostgreSQL type, allowing e.g. an ID from a route.
|
|
253
|
+
return if rhs.kind == :value && right[:type] == :text
|
|
254
|
+
return if right[:type] == :date && left[:type] == :timestamptz
|
|
255
|
+
fail!("incompatible comparison types", clause)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def aggregate?(node)
|
|
259
|
+
return false unless node.is_a?(Node)
|
|
260
|
+
return true if node.kind == :count || (node.kind == :raw && node.options[:aggregate])
|
|
261
|
+
node.args.any? { |arg| arg.is_a?(Node) && aggregate?(arg) }
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def check_group(node, clause)
|
|
265
|
+
return if state[:group].any? { |group| group.key == node.key }
|
|
266
|
+
return if node.kind == :count || node.kind == :value
|
|
267
|
+
return if node.kind == :raw && node.options[:aggregate]
|
|
268
|
+
fail!("nonaggregate expression must be explicitly grouped", clause) if [:column, :raw].include?(node.kind)
|
|
269
|
+
node.args.each { |arg| check_group(arg, clause) if arg.is_a?(Node) }
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|