jade-sql 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,146 @@
1
+ module JadeSql
2
+ module Compiler
3
+ module Assignable
4
+ extend self
5
+ include Helpers
6
+
7
+ INTERFACE = 'Sql.Assignable'
8
+ ASSIGNMENT = 'Sql.Assignment'
9
+ ASSIGNMENT_FIELDS = %w[col value_sql params].freeze
10
+
11
+ def supports?(interface) = interface == INTERFACE
12
+
13
+ def derive(constraint, registry, entry_name, &lookup)
14
+ return failed(constraint, entry_name) unless assignment_matches?(registry)
15
+
16
+ case constraint.type
17
+ in Type::Application(constructor: Type::Constructor(name:), args:)
18
+ Symbol
19
+ .type_ref_from_qualified_name(name)
20
+ .then { registry.lookup(it) }
21
+ .then { derive_for(constraint, it, args, registry, lookup, entry_name) }
22
+
23
+ else
24
+ failed(constraint, entry_name)
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ # The interface is matched by name, so some other module called `Sql`
31
+ # would otherwise be derived against as though it were jade-sql.
32
+ def assignment_matches?(registry)
33
+ Symbol
34
+ .type_ref_from_qualified_name(ASSIGNMENT)
35
+ .then { registry.lookup(it) }
36
+ .then do
37
+ it in Symbol::Struct(record_type: { fields: }) and
38
+ fields.keys.map(&:to_s) == ASSIGNMENT_FIELDS
39
+ end
40
+ end
41
+
42
+ def derive_for(constraint, symbol, args, registry, lookup, entry_name)
43
+ case symbol
44
+ in Symbol::Union if args.empty? && single_payload?(symbol, registry)
45
+ derive_union(constraint, symbol, registry, lookup, entry_name)
46
+
47
+ in Symbol::Struct
48
+ derive_struct(constraint, symbol, args, registry, lookup, entry_name)
49
+
50
+ else
51
+ failed(constraint, entry_name)
52
+ end
53
+ end
54
+
55
+ # One column per variant, so each variant carries exactly the value
56
+ # that column is set to.
57
+ def single_payload?(union_sym, registry)
58
+ variants(union_sym, registry)
59
+ .then { it.any? && it.all? { it.args.size == 1 } }
60
+ end
61
+
62
+ def derive_union(constraint, union_sym, registry, lookup, entry_name)
63
+ vs = variants(union_sym, registry)
64
+
65
+ vs
66
+ .map { encodable_dep(it, registry) }
67
+ .map { lookup.call(it) }
68
+ .then { Results.sequence(it) }
69
+ .map { assignable(constraint, union_body(vs), it) }
70
+ end
71
+
72
+ def derive_struct(constraint, struct_sym, args, registry, lookup, entry_name)
73
+ fields = struct_fields(struct_sym, args, registry)
74
+
75
+ fields
76
+ .map { |_, type| Type.constraint('Encode.Encodable', type, nil) }
77
+ .map { lookup.call(it) }
78
+ .then { Results.sequence(it) }
79
+ .map { assignable(constraint, struct_body(fields), it) }
80
+ end
81
+
82
+ def struct_body(fields)
83
+ fields
84
+ .each_with_index
85
+ .map { |(name, _), idx| field_assignment(name, idx) }
86
+ .then { [:list, it] }
87
+ end
88
+
89
+ def field_assignment(name, idx)
90
+ [:call,
91
+ [:struct_constructor, ASSIGNMENT, 3],
92
+ [
93
+ Compiler.column_name(name),
94
+ '?',
95
+ [:list,
96
+ [[:call, [:impl_arg, idx, 'encoder'], [[:access, [:var, 'f'], name.to_s]]]],
97
+ ],
98
+ ],
99
+ ]
100
+ end
101
+
102
+ def encodable_dep(variant, registry)
103
+ variant
104
+ .args
105
+ .first
106
+ .then { instantiate(it, {}, registry) }
107
+ .then { Type.constraint('Encode.Encodable', it, nil) }
108
+ end
109
+
110
+ def union_body(variants)
111
+ variants
112
+ .each_with_index
113
+ .map { |v, idx| [[:constructor, v.qualified_name, ['x']], [assignment(v, idx)]] }
114
+ .then { [:case, [:var, 'f'], it] }
115
+ end
116
+
117
+ def assignment(variant, idx)
118
+ [:call,
119
+ [:struct_constructor, ASSIGNMENT, 3],
120
+ [
121
+ wire_name(variant),
122
+ '?',
123
+ [:list, [[:call, [:impl_arg, idx, 'encoder'], [[:var, 'x']]]]],
124
+ ],
125
+ ]
126
+ .then { [:list, [it]] }
127
+ end
128
+
129
+ def failed(constraint, entry_name)
130
+ Err[
131
+ DerivationFailed.new(
132
+ entry_name, constraint.origin&.range, constraint:, trace: [],
133
+ )
134
+ ]
135
+ end
136
+
137
+ def assignable(constraint, body, deps)
138
+ implementation(
139
+ constraint,
140
+ { 'to_assigns' => Symbol::DerivedFunction.new(params: ['f'], body:) },
141
+ deps:,
142
+ )
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,220 @@
1
+ module JadeSql
2
+ module Compiler
3
+ # A struct's fields map onto a table's columns by name, and the mapping
4
+ # derives, so nothing else ever compares the two — a field the table has
5
+ # no column for reaches Postgres as invalid SQL. Both types are concrete
6
+ # at the call site, which is why this is a check and not a constraint.
7
+ module Columns
8
+ extend self
9
+ include Helpers
10
+
11
+ TABLE = 'Sql.Table'
12
+ EXPR = 'Sql.Expr'
13
+ LIST = 'List.List'
14
+ TUPLE2 = 'Tuple.Tuple2'
15
+ ASSIGNABLE = 'Sql.Assignable'
16
+ STAMPED = 'Sql.Write.Stamped'
17
+ STAMPS = %w[created_at updated_at].freeze
18
+
19
+ # Where the written value is, where the table is, what the value arrives
20
+ # wrapped in, and whether the row is being created — only then does a
21
+ # column the database cannot fill have to be written.
22
+ #
23
+ # Every write taking a struct of the caller's is here. `update_all` and
24
+ # `delete_all` are not, because they build their SET and WHERE from the
25
+ # table's own `c` and `s` — there is no struct to compare.
26
+ Target = Data.define(:value_at, :table_at, :wrapper, :creates)
27
+
28
+ TARGETS = {
29
+ 'Sql.Write.insert' => Target[0, 1, :bare, true],
30
+ 'Sql.Write.insert_all' => Target[0, 1, :list, true],
31
+ 'Sql.Write.update' => Target[0, 1, :bare, false],
32
+ 'Sql.Write.update_many' => Target[0, 1, :keyed_list, false],
33
+ }.freeze
34
+
35
+ def watches = TARGETS.keys
36
+
37
+ def check(ctx)
38
+ TARGETS.fetch(ctx.name).then do |target|
39
+ table = ctx.arg_types[target.table_at]
40
+ value, stamps = unstamp(unwrap(ctx.arg_types[target.value_at], target.wrapper))
41
+
42
+ next [] if written?(value, ctx.registry)
43
+
44
+ compare(value, cols_of(table), ctx) +
45
+ (target.creates ? missing(value, stamps, table, ctx) : [])
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ # A field maps to the column of the same name only because the deriver
52
+ # says so. An author who wrote `to_assigns` by hand has already said
53
+ # what the columns are — one field may become two, or none — so there
54
+ # is nothing here to compare against.
55
+ def written?(value, registry)
56
+ case implementation(value, registry)
57
+ in Symbol::Implementation(module_name: String) then true
58
+ else false
59
+ end
60
+ end
61
+
62
+ def implementation(value, registry)
63
+ case value
64
+ in Type::Application(constructor: Type::Constructor(name:))
65
+ registry.implementations[[ASSIGNABLE, name]]
66
+
67
+ else nil
68
+ end
69
+ end
70
+
71
+ # `r` names the columns the database will not fill in, so a value that
72
+ # writes none of them produces a row Postgres rejects. `timestamped` writes
73
+ # two of them without them being fields, which is why it is carried
74
+ # alongside the value's own.
75
+ def missing(value, stamps, table, ctx)
76
+ case [fields_of(value, ctx.registry), columns_of(required_of(table), ctx.registry)]
77
+ in [Array => fields, Hash => required]
78
+ required.keys - fields.map { Compiler.column_name(it.first) } - stamps
79
+
80
+ else []
81
+ end
82
+ .then do |absent|
83
+ next [] if absent.empty?
84
+
85
+ [Errors::MissingColumns.new(
86
+ ctx.entry_name, ctx.span,
87
+ struct: name_of(value), table: name_of(cols_of(table)), missing: absent,
88
+ )]
89
+ end
90
+ end
91
+
92
+ # `Table(c, m, k, o, r, s)` — the required columns are its fifth
93
+ # argument. Matched by position rather than "the last one", so appending
94
+ # a parameter cannot silently point this at something else.
95
+ def required_of(table)
96
+ case table
97
+ in Type::Application(
98
+ constructor: Type::Constructor(name: TABLE), args: [_, _, _, _, required, _]
99
+ )
100
+ required
101
+
102
+ else nil
103
+ end
104
+ end
105
+
106
+ # `timestamped` writes `created_at` and `updated_at` on a value that has no
107
+ # such fields, so it answers for them without appearing among them.
108
+ def unstamp(value)
109
+ case value
110
+ in Type::Application(constructor: Type::Constructor(name: STAMPED), args: [inner])
111
+ [inner, STAMPS]
112
+
113
+ else [value, []]
114
+ end
115
+ end
116
+
117
+ # Either side may be absent — an argument the caller left off, a table
118
+ # still polymorphic in its columns — and then there is nothing to compare.
119
+ def compare(value, cols, ctx)
120
+ case [fields_of(value, ctx.registry), columns_of(cols, ctx.registry)]
121
+ in [Array => fields, Hash => columns]
122
+ fields.filter_map { mismatch(it, columns, value, cols, ctx) }
123
+
124
+ else []
125
+ end
126
+ end
127
+
128
+ def mismatch((name, type), columns, value, cols, ctx)
129
+ column = Compiler.column_name(name)
130
+
131
+ case columns[column]
132
+ in nil
133
+ Errors::UnknownColumn.new(
134
+ ctx.entry_name, ctx.span,
135
+ struct: name_of(value), field: name, table: name_of(cols),
136
+ columns: columns.keys,
137
+ )
138
+
139
+ in ^type
140
+ nil
141
+
142
+ in found
143
+ Errors::ColumnTypeMismatch.new(
144
+ ctx.entry_name, ctx.span,
145
+ struct: name_of(value), field: name, table: name_of(cols),
146
+ column:, expected: found, actual: type,
147
+ )
148
+ end
149
+ end
150
+
151
+ # `Table(c, m, k, ...)` — the columns are its first argument.
152
+ def cols_of(table)
153
+ case table
154
+ in Type::Application(constructor: Type::Constructor(name: TABLE), args: [cols, *])
155
+ cols
156
+
157
+ else nil
158
+ end
159
+ end
160
+
161
+ # Each column is an `Expr(t)` whose `t` is what the field has to be.
162
+ def columns_of(cols, registry)
163
+ fields_of(cols, registry)
164
+ &.to_h { |field, type| [Compiler.column_name(field), unwrap_expr(type)] }
165
+ end
166
+
167
+ def fields_of(type, registry)
168
+ case type
169
+ in Type::Application(constructor: Type::Constructor(name:), args:)
170
+ Symbol
171
+ .type_ref_from_qualified_name(name)
172
+ .then { registry.lookup(it) }
173
+ .then { (it in Symbol::Struct) ? struct_fields(it, args, registry) : nil }
174
+
175
+ else nil
176
+ end
177
+ end
178
+
179
+ def unwrap(type, wrapper)
180
+ case [wrapper, type]
181
+ in [:list, Type::Application(constructor: Type::Constructor(name: LIST), args: [inner])]
182
+ inner
183
+
184
+ # `update_many` takes the key alongside the value, so the struct is the
185
+ # second half of each pair rather than the element itself.
186
+ in [
187
+ :keyed_list,
188
+ Type::Application(
189
+ constructor: Type::Constructor(name: LIST),
190
+ args: [Type::Application(
191
+ constructor: Type::Constructor(name: TUPLE2), args: [_, inner]
192
+ )],
193
+ )
194
+ ]
195
+ inner
196
+
197
+ else type
198
+ end
199
+ end
200
+
201
+ def unwrap_expr(type)
202
+ case type
203
+ in Type::Application(constructor: Type::Constructor(name: EXPR), args: [inner])
204
+ inner
205
+
206
+ else type
207
+ end
208
+ end
209
+
210
+ def name_of(type)
211
+ case type
212
+ in Type::Application(constructor: Type::Constructor(name:), args: _)
213
+ name.split('.').last
214
+
215
+ else type.to_s
216
+ end
217
+ end
218
+ end
219
+ end
220
+ end
@@ -0,0 +1,82 @@
1
+ module JadeSql
2
+ module Compiler
3
+ module Errors
4
+ class UnknownColumn < Jade::Error
5
+ def initialize(entry, span, struct:, field:, table:, columns:)
6
+ @struct = struct
7
+ @field = field
8
+ @table = table
9
+ @columns = columns
10
+ super(entry:, span:)
11
+ end
12
+
13
+ def message
14
+ "#{@struct}.#{@field} has no column on #{@table}"
15
+ end
16
+
17
+ def label
18
+ "no column `#{@field}`"
19
+ end
20
+
21
+ def queried_name = @field.to_s
22
+
23
+ def candidates = @columns
24
+ end
25
+
26
+ class MissingColumns < Jade::Error
27
+ STAMPS = %w[created_at updated_at].freeze
28
+
29
+ def initialize(entry, span, struct:, table:, missing:)
30
+ @struct = struct
31
+ @table = table
32
+ @missing = missing
33
+ super(entry:, span:)
34
+ end
35
+
36
+ def message
37
+ "#{@struct} does not write #{list(@missing)}, " \
38
+ "which #{@table} requires"
39
+ end
40
+
41
+ def label
42
+ "missing #{list(@missing)}"
43
+ end
44
+
45
+ def notes
46
+ return [] unless (@missing - STAMPS).empty?
47
+
48
+ [Jade::Diagnostics::Annotation[:help, 'pipe the value through `timestamped`']]
49
+ end
50
+
51
+ private
52
+
53
+ def list(names)
54
+ names.map { "`#{it}`" }.then do |quoted|
55
+ quoted.length == 1 ? quoted.first : "#{quoted[..-2].join(', ')} and #{quoted.last}"
56
+ end
57
+ end
58
+ end
59
+
60
+ class ColumnTypeMismatch < Jade::Error
61
+ def initialize(entry, span, struct:, field:, table:, column:, expected:, actual:)
62
+ @struct = struct
63
+ @field = field
64
+ @table = table
65
+ @column = column
66
+ @expected = expected
67
+ @actual = actual
68
+ super(entry:, span:)
69
+ end
70
+
71
+ def message
72
+ "#{@table}.#{@column} is #{@expected}, " \
73
+ "but #{@struct}.#{@field} is #{@actual}"
74
+ end
75
+
76
+ def label
77
+ "column is #{@expected}, field is #{@actual}"
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,32 @@
1
+ require 'jade'
2
+
3
+ module JadeSql
4
+ # jade-sql's half of the compiler: an interface it derives and a check it
5
+ # runs, both registered through `Jade::Extensions`.
6
+ module Compiler
7
+ Type = Jade::Type
8
+ Symbol = Jade::Symbol
9
+ Results = Jade::Results
10
+ Ok = Jade::Ok
11
+ Err = Jade::Err
12
+ Lexer = Jade::Lexer
13
+ Helpers = Jade::Frontend::TypeChecking::Constraints::Deriving::Helpers
14
+ DerivationFailed = Jade::Frontend::TypeChecking::Error::DerivationFailed
15
+
16
+ # The generator renames a column that collides with a jade keyword, so
17
+ # `type_` maps back to the `type` it came from.
18
+ def self.column_name(field)
19
+ field
20
+ .to_s
21
+ .then { it.end_with?('_') ? it.delete_suffix('_') : it }
22
+ .then { Lexer::KEYWORDS.include?(it) ? it : field.to_s }
23
+ end
24
+ end
25
+ end
26
+
27
+ require_relative 'compiler/errors'
28
+ require_relative 'compiler/assignable'
29
+ require_relative 'compiler/columns'
30
+
31
+ Jade::Extensions.register_deriver('jade-sql', JadeSql::Compiler::Assignable)
32
+ Jade::Extensions.register_check('jade-sql', :call, JadeSql::Compiler::Columns)
@@ -11,59 +11,57 @@ module JadeSql
11
11
 
12
12
  task :port_execute_count do |t, sql, params|
13
13
  conn = ::ActiveRecord::Base.connection
14
- t.ok(conn.exec_update(adapt_sql(fill_now(sql), conn), "Jade", typed_params(params, conn)))
15
- rescue ::ActiveRecord::RecordNotUnique => e
16
- t.err(JadeSql::SqlErrors.conflict(constraint_name(e)))
14
+ t.ok(conn.exec_update(statement(sql, params, conn), "Jade", typed_params(params, conn)))
17
15
  rescue ::ActiveRecord::StatementInvalid => e
18
- t.err(JadeSql::SqlErrors.db_error(e.message))
16
+ t.err(translate(e))
19
17
  end
20
18
 
21
19
  task :port_execute_one do |t, sql, params|
22
20
  conn = ::ActiveRecord::Base.connection
23
- rows = conn.exec_query(adapt_sql(fill_now(sql), conn), "Jade", typed_params(params, conn)).to_a
21
+ rows = conn.exec_query(statement(sql, params, conn), "Jade", typed_params(params, conn)).to_a
24
22
  case rows.length
25
23
  when 0 then t.err(JadeSql::SqlErrors.not_found)
26
24
  when 1 then t.ok(coerce_row(rows.first))
27
- else t.err(JadeSql::SqlErrors.not_unique)
25
+ else t.err(JadeSql::SqlErrors.too_many_rows)
28
26
  end
29
- rescue ::ActiveRecord::RecordNotUnique => e
30
- t.err(JadeSql::SqlErrors.conflict(constraint_name(e)))
31
27
  rescue ::ActiveRecord::StatementInvalid => e
32
- t.err(JadeSql::SqlErrors.db_error(e.message))
28
+ t.err(translate(e))
33
29
  end
34
30
 
35
31
  task :port_execute_many do |t, sql, params|
36
32
  conn = ::ActiveRecord::Base.connection
37
- rows = conn.exec_query(adapt_sql(fill_now(sql), conn), "Jade", typed_params(params, conn)).to_a
33
+ rows = conn.exec_query(statement(sql, params, conn), "Jade", typed_params(params, conn)).to_a
38
34
  t.ok(rows.map { |row| coerce_row(row) })
39
- rescue ::ActiveRecord::RecordNotUnique => e
40
- t.err(JadeSql::SqlErrors.conflict(constraint_name(e)))
41
35
  rescue ::ActiveRecord::StatementInvalid => e
42
- t.err(JadeSql::SqlErrors.db_error(e.message))
36
+ t.err(translate(e))
43
37
  end
44
38
 
45
39
  # Transaction control on the shared connection. The execute/fetch ports
46
40
  # above use the same `ActiveRecord::Base.connection`, so anything they
47
41
  # run between begin and commit/rollback is part of this transaction.
48
- # These bypass AR's transaction manager (no savepoints), so they don't
49
- # nest see `Sql.transaction`. Rollback is best-effort: it swallows
50
- # adapter errors so the original failure is the one that propagates.
42
+ #
43
+ # Not the raw `begin_db_transaction` family: those only emit SQL, so a
44
+ # second BEGIN is a warning and its COMMIT ends whichever transaction was
45
+ # already running. The manager issues a SAVEPOINT instead.
46
+ #
47
+ # Rollback is best-effort: it swallows adapter errors so the original
48
+ # failure is the one that propagates.
51
49
  task :port_begin do |t|
52
- ::ActiveRecord::Base.connection.begin_db_transaction
50
+ ::ActiveRecord::Base.connection.begin_transaction(joinable: false)
53
51
  t.ok(true)
54
52
  rescue ::ActiveRecord::StatementInvalid => e
55
53
  t.err(JadeSql::SqlErrors.db_error(e.message))
56
54
  end
57
55
 
58
56
  task :port_commit do |t|
59
- ::ActiveRecord::Base.connection.commit_db_transaction
57
+ ::ActiveRecord::Base.connection.commit_transaction
60
58
  t.ok(true)
61
59
  rescue ::ActiveRecord::StatementInvalid => e
62
60
  t.err(JadeSql::SqlErrors.db_error(e.message))
63
61
  end
64
62
 
65
63
  task :port_rollback do |t|
66
- ::ActiveRecord::Base.connection.rollback_db_transaction
64
+ ::ActiveRecord::Base.connection.rollback_transaction
67
65
  t.ok(true)
68
66
  rescue ::ActiveRecord::StatementInvalid
69
67
  t.ok(true)
@@ -173,9 +171,36 @@ module JadeSql
173
171
  raw == "NULL" ? nil : raw
174
172
  end
175
173
 
176
- # The constraint/index name behind a RecordNotUnique, so callers can route
177
- # by which unique index was violated. PG reports it in the error's
178
- # diagnostics; other adapters (or a missing name) fall back to "".
174
+ # ActiveRecord already tells these apart by SQLSTATE, and dropping that
175
+ # into a message is what forced callers to match text.
176
+ def self.translate(error)
177
+ case error
178
+ when ::ActiveRecord::RecordNotUnique
179
+ JadeSql::SqlErrors.unique_violation(constraint_name(error))
180
+ when ::ActiveRecord::InvalidForeignKey
181
+ JadeSql::SqlErrors.foreign_key_violation(constraint_name(error))
182
+ when ::ActiveRecord::CheckViolation
183
+ JadeSql::SqlErrors.check_violation(constraint_name(error))
184
+ when ::ActiveRecord::ExclusionViolation
185
+ JadeSql::SqlErrors.exclusion_violation(constraint_name(error))
186
+ when ::ActiveRecord::NotNullViolation
187
+ JadeSql::SqlErrors.not_null_violation(column_name(error))
188
+ when ::ActiveRecord::Deadlocked
189
+ JadeSql::SqlErrors.deadlock
190
+ when ::ActiveRecord::SerializationFailure
191
+ JadeSql::SqlErrors.serialization_failure
192
+ when ::ActiveRecord::LockWaitTimeout
193
+ JadeSql::SqlErrors.lock_timeout
194
+ when ::ActiveRecord::QueryCanceled, ::ActiveRecord::StatementTimeout
195
+ JadeSql::SqlErrors.statement_timeout
196
+ else
197
+ JadeSql::SqlErrors.db_error(error.message)
198
+ end
199
+ end
200
+
201
+ # The constraint/index name behind a violation, so callers can route by
202
+ # which one it was. PG reports it in the error's diagnostics; other
203
+ # adapters (or a missing name) fall back to "".
179
204
  def self.constraint_name(error)
180
205
  cause = error.cause
181
206
  return "" unless defined?(::PG::Result) && cause.respond_to?(:result) && cause.result
@@ -185,7 +210,16 @@ module JadeSql
185
210
  ""
186
211
  end
187
212
 
188
- # Sql.Mutation.timestamped emits "$JADE_SQL_NOW$" where created_at /
213
+ def self.column_name(error)
214
+ cause = error.cause
215
+ return "" unless defined?(::PG::Result) && cause.respond_to?(:result) && cause.result
216
+
217
+ cause.result.error_field(::PG::Result::PG_DIAG_COLUMN_NAME) || ""
218
+ rescue StandardError
219
+ ""
220
+ end
221
+
222
+ # Sql.Write.timestamped emits "$JADE_SQL_NOW$" where created_at /
189
223
  # updated_at go. Swap it for one UTC timestamp literal per statement,
190
224
  # computed here — the app clock, so it moves with travel_to/Timecop
191
225
  # (unlike DB now()). The token lives in SQL we generate, never in a
@@ -199,6 +233,23 @@ module JadeSql
199
233
  sql.gsub(NOW_TOKEN) { stamp }
200
234
  end
201
235
 
236
+ def self.statement(sql, params, conn)
237
+ fill_now(sql)
238
+ .tap { refuse_stacked(it) if params.empty? }
239
+ .then { adapt_sql(it, conn) }
240
+ end
241
+
242
+ def self.refuse_stacked(sql)
243
+ at = sql.sub(/;\s*\z/, '').index(';')
244
+ return if at.nil?
245
+
246
+ raise ArgumentError,
247
+ "jade-sql refused a statement with a second one after `;` " \
248
+ "(at character #{at + 1}). Without bound values, Postgres would run " \
249
+ "every statement in the string. If a value holds the `;`, bind it " \
250
+ "with `?`. If you meant two statements, make two calls."
251
+ end
252
+
202
253
  # Sql renders `?` placeholders uniformly. AR's exec_query/exec_update
203
254
  # path on the PG adapter expects `$1, $2, …` — there is no `?`-to-`$n`
204
255
  # rewrite at that layer. SQLite and MySQL accept `?` directly, so this