quote-sql 0.0.1
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/README.md +123 -0
- data/lib/quote_sql/connector/active_record_base.rb +37 -0
- data/lib/quote_sql/connector.rb +13 -0
- data/lib/quote_sql/deprecated.rb +162 -0
- data/lib/quote_sql/extension.rb +14 -0
- data/lib/quote_sql/formater.rb +23 -0
- data/lib/quote_sql/quoter.rb +188 -0
- data/lib/quote_sql/quoting.rb +48 -0
- data/lib/quote_sql/test.rb +112 -0
- data/lib/quote_sql.rb +306 -0
- metadata +55 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 13f64b3b81c62230c2814c487d3a5b2e56109b651297a599449a2b8edd91b3d8
|
4
|
+
data.tar.gz: 8fc4b373eb65ab1d6ba2e86a32db5d60caf3bef38096e9a098127d9f892a4abe
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: d58cc18c35110eef2ddb9c08f1e23f9b353ab7fc73939063fa3a576156c0fd96b05099e8fe8742fcb4c46261735fc47d547d24f6218ce9f6776c8e91be873211
|
7
|
+
data.tar.gz: 7facea8c215baf9c9196512b636b3ae866343eea0ca8137589b25f3cd6684a4fa815661c05059be58c11ace2c0fc6ac3962d96d02c3c9be1fdef628a00c6349d
|
data/README.md
ADDED
@@ -0,0 +1,123 @@
|
|
1
|
+
# QuoteSql - Tool to build and run SQL queries easier
|
2
|
+
I've built this library as an addition to ActiveRecord and Arel, however you can use it with any sql database and plain Ruby.
|
3
|
+
|
4
|
+
Creating SQL queries and proper quoting becomes complicated especially when you need advanced queries.
|
5
|
+
|
6
|
+
I created this library while coding for different projects, and had lots of Heredoc SQL queries, which pretty quickly becomes the kind of:
|
7
|
+
> When I wrote these lines of code, just me and God knew what they mean. Now its just God.
|
8
|
+
|
9
|
+
My strategy is to segment SQL Queries in readable junks, which can be individually tested and then combine their sql to the final query.
|
10
|
+
|
11
|
+
QuoteSql is used in production, but is still evolving.
|
12
|
+
|
13
|
+
If you think QuoteSql is interesting, let's chat!
|
14
|
+
Also if you have problems using it, just drop me a note.
|
15
|
+
|
16
|
+
Best Martin
|
17
|
+
|
18
|
+
## Examples
|
19
|
+
### Simple quoting
|
20
|
+
`QuoteSql.new("SELECT %field").quote(field: "abc").to_sql`
|
21
|
+
=> SELECT 'abc'
|
22
|
+
|
23
|
+
`QuoteSql.new("SELECT %field__text").quote(field__text: 9).to_sql`
|
24
|
+
=> SELECT 9::TEXT
|
25
|
+
|
26
|
+
### Quoting of columns and table from a model - or an object responding to table_name and column_names or columns
|
27
|
+
`QuoteSql.new("SELECT %columns FROM %table_name").quote(table: User).to_sql`
|
28
|
+
=> SELECT "id",firstname","lastname",... FROM "users"
|
29
|
+
### Injecting raw sql in a query
|
30
|
+
`QuoteSql.new("SELECT a,b,%raw FROM table").quote(raw: "jsonb_build_object('a', 1)").to_sql`
|
31
|
+
=> SELECT "a,b,jsonb_build_object('a', 1) FROM table
|
32
|
+
|
33
|
+
### Injecting ActiveRecord, Arel.sql or QuoteSql
|
34
|
+
`QuoteSql.new("SELECT %column_names FROM (%any_name) a").
|
35
|
+
quote(any_name: User.select("%column_names").where(id: 3), column_names: [:firstname, :lastname]).to_sql`
|
36
|
+
=> SELECT firstname, lastname FROM (SELECT firstname, lastname FROM users where id = 3)
|
37
|
+
|
38
|
+
### Insert of values quoted and sorted with columns
|
39
|
+
Values are be ordered in sequence of columns. Missing value entries are substitured with DEFAULT.
|
40
|
+
`QuoteSql.new("INSERT INTO %table (%columns) VALUES %values ON CONFLICT (%constraint) DO NOTHING").
|
41
|
+
quote(table: User, values: [
|
42
|
+
{firstname: "Albert", id: 1, lastname: "Müller"},
|
43
|
+
{lastname: "Schultz", firstname: "herbert"}
|
44
|
+
], constraint: :id).to_sql`
|
45
|
+
=> INSERT INTO "users" ("id", "firstname", "lastname", "created_at")
|
46
|
+
VALUES (1, 'Albert', 'Müller', CURRENT_TIMESTAMP), (DEFAULT, 'herbert', 'Schultz', CURRENT_TIMESTAMP)
|
47
|
+
ON CONFLICT ("id") DO NOTHING
|
48
|
+
|
49
|
+
### Columns from a list
|
50
|
+
`QuoteSql.new("SELECT %columns").quote(columns: [:a, :"b.c", c: "jsonb_build_object('d', 1)"]).to_sql`
|
51
|
+
=> SELECT "a","b"."c",jsonb_build_object('d', 1) AS c
|
52
|
+
|
53
|
+
## Substitution of
|
54
|
+
In the SQL matches of `%foo` or `%{foo}` or `%foo_4_bar` or `%{foo_4_bar}` the *"mixins"*
|
55
|
+
are substituted with quoted values
|
56
|
+
the values are looked up from the options given in the quotes method
|
57
|
+
the mixins can be recursive.
|
58
|
+
**Caution! You need to take care, no protection against infinite recursion **
|
59
|
+
|
60
|
+
### Special mixins
|
61
|
+
- `%table` | `%table_name` | `%table_names`
|
62
|
+
- `%column` | `%columns` | `%column_names`
|
63
|
+
- `%ident` | `%constraint` | `%constraints` quoting for database columns
|
64
|
+
- `%raw` | `%sql` inserting raw SQL
|
65
|
+
- `%value` | `%values` creates value section for e.g. insert
|
66
|
+
- In the right order
|
67
|
+
- Single value => (2)
|
68
|
+
- +Array+ => (column, column, column) n.b. has to be the correct order
|
69
|
+
- +Array+ of +Array+ => (...),(...),(...),...
|
70
|
+
- if the columns option is given (or implicitely by setting table)
|
71
|
+
- +Hash+ values are ordered according to the columns option, missing values are replaced by `DEFAULT`
|
72
|
+
- +Array+ of +Hash+ multiple record insert
|
73
|
+
- `%bind` is replaced with the current bind sequence.
|
74
|
+
Without appended number the first %bind => $1, the second => $2 etc.
|
75
|
+
- %bind\\d+ => $+Integer+ e.g. `%bind7` => $7
|
76
|
+
- `%bind__text` => $1 and it is registered as text - this is used in prepared statements (TO BE IMPLEMENTED)
|
77
|
+
- `%key_bind__text` => $1 and it is registered as text when using +Hash+ in the execute
|
78
|
+
$1 will be mapped to the key's value in the +Hash+ TODO
|
79
|
+
|
80
|
+
All can be preceded by additional letters and underscore e.g. `%foo_bar_column`
|
81
|
+
|
82
|
+
### Type casts
|
83
|
+
A database typecast is added to fields ending with double underscore and a valid db data type
|
84
|
+
with optional array dimension
|
85
|
+
|
86
|
+
- `%field__jsonb` => adds a `::JSONB` typecast to the field
|
87
|
+
- `%number_to__text` => adds a `::TEXT` typecast to the field
|
88
|
+
- `%array__text1` => adds a `::TEXT[]` (TO BE IMPLEMENTED)
|
89
|
+
- `%array__text2` => adds a `::TEXT[][]` (TO BE IMPLEMENTED)
|
90
|
+
|
91
|
+
### Quoting
|
92
|
+
- Any value of the standard mixins are quoted with these exceptions
|
93
|
+
- +Array+ are quoted as DB Arrays unless a type cast is given e.g. __jsonb
|
94
|
+
- +Hash+ are quoted as jsonb unless a type cast is given e.g. __json
|
95
|
+
- When the value responds to :to_sql or is a +Arel::Nodes::SqlLiteral+ its added as raw SQL
|
96
|
+
- +Proc+ are executed with the +QuoteSQL::Quoter+ object as parameter and added as raw SQL
|
97
|
+
|
98
|
+
### Special quoting columns
|
99
|
+
- +String+ or +Symbol+ without a dot e.g. :firstname => "firstname"
|
100
|
+
- +String+ or +Symbol+ containing a dot e.g. "users.firstname" or => "users"."firstname"
|
101
|
+
- +Array+
|
102
|
+
- +String+ and +Symbols+ see above
|
103
|
+
- +Hash+ see below
|
104
|
+
- +Hash+ or within the +Array+
|
105
|
+
- +Symbol+ value will become the column name e.g. {table: :column} => "table"."column"
|
106
|
+
- +String+ value will become the expression, the key the AS {result: "SUM(*)"} => SUM(*) AS result
|
107
|
+
- +Proc+ are executed with the +QuoteSQL::Quoter+ object as parameter and added as raw SQL
|
108
|
+
|
109
|
+
## Installing
|
110
|
+
`gem install quote-sql`
|
111
|
+
or in Gemfile
|
112
|
+
`gem 'quote-sql'`
|
113
|
+
|
114
|
+
### Ruby on Rails
|
115
|
+
Add this to config/initializers/quote_sql.rb
|
116
|
+
|
117
|
+
ActiveSupport.on_load(:active_record) do
|
118
|
+
require 'quote_sql'
|
119
|
+
QuoteSql.db_connector = ActiveRecord::Base
|
120
|
+
String.include QuoteSql::Extension
|
121
|
+
ActiveRecord::Relation.include QuoteSql::Extension
|
122
|
+
end
|
123
|
+
|
@@ -0,0 +1,37 @@
|
|
1
|
+
class QuoteSql
|
2
|
+
module Connector
|
3
|
+
module ActiveRecordBase
|
4
|
+
module ClassMethods
|
5
|
+
def conn
|
6
|
+
::ActiveRecord::Base.connection
|
7
|
+
end
|
8
|
+
|
9
|
+
def quote_column_name(name)
|
10
|
+
conn.quote_column_name(name)
|
11
|
+
end
|
12
|
+
|
13
|
+
def quote(name)
|
14
|
+
conn.quote(name)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def conn
|
19
|
+
self.class.conn
|
20
|
+
end
|
21
|
+
|
22
|
+
def _exec_query(sql, binds = [], prepare: false, async: false)
|
23
|
+
conn.exec_query(sql, "SQL", binds, prepare:, async:)
|
24
|
+
end
|
25
|
+
|
26
|
+
def _exec(sql, binds = [], prepare: false, async: false)
|
27
|
+
options = { prepare:, async: }
|
28
|
+
result = _exec_query(sql, binds, **options)
|
29
|
+
columns = result.columns.map(&:to_sym)
|
30
|
+
result.cast_values.map do |row|
|
31
|
+
row = [row] unless row.is_a? Array
|
32
|
+
[columns, row].transpose.to_h
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
class QuoteSql
|
2
|
+
module Connector
|
3
|
+
def self.set(klass)
|
4
|
+
file = "/" + klass.to_s.underscore.tr("/", "_")
|
5
|
+
require __FILE__.sub(/\.rb$/, file)
|
6
|
+
const_set :CONNECTOR, (to_s + file.classify).constantize
|
7
|
+
QuoteSql.include CONNECTOR
|
8
|
+
class << QuoteSql
|
9
|
+
prepend CONNECTOR::ClassMethods
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,162 @@
|
|
1
|
+
class QuoteSql
|
2
|
+
module Deprecated
|
3
|
+
private def conn
|
4
|
+
ApplicationRecord.connection
|
5
|
+
end
|
6
|
+
|
7
|
+
private def quote_sql_values(sub, casts)
|
8
|
+
sub.map do |s|
|
9
|
+
casts.map do |k, column|
|
10
|
+
column.transform_keys(&:to_sym) => { sql_type:, default:, array: }
|
11
|
+
value = s.key?(k) ? s[k] : s[k.to_sym]
|
12
|
+
if value.nil?
|
13
|
+
value = default
|
14
|
+
else
|
15
|
+
value = value.to_json if sql_type[/^json/]
|
16
|
+
end
|
17
|
+
"#{conn.quote(value)}::#{sql_type}"
|
18
|
+
end.join(",")
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def quote_sql(**options)
|
23
|
+
loop do
|
24
|
+
# keys = []
|
25
|
+
break unless gsub!(%r{(?<=^|\W)[:$](#{options.keys.join("|")})(?=\W|$)}) do |m|
|
26
|
+
key = m[1..].to_sym
|
27
|
+
# keys << key
|
28
|
+
next m unless options.key? key
|
29
|
+
sub = options[key]
|
30
|
+
case sub
|
31
|
+
when Arel::Nodes::SqlLiteral
|
32
|
+
next sub
|
33
|
+
when NilClass
|
34
|
+
next "NULL"
|
35
|
+
when TrueClass, FalseClass
|
36
|
+
next sub.to_s.upcase
|
37
|
+
when Time
|
38
|
+
sub = sub.strftime("%Y-%m-%d %H:%M:%S.%3N%z")
|
39
|
+
end
|
40
|
+
if sub.respond_to? :to_sql
|
41
|
+
next sub.to_sql
|
42
|
+
end
|
43
|
+
case m
|
44
|
+
when /^:(.+)_(FROM_CLAUSE)$/ # prefix (column,...) AS ( VALUES (data::CAST, ...), ...)
|
45
|
+
name = conn.quote_column_name($1)
|
46
|
+
casts = sub.shift.transform_keys(&:to_s)
|
47
|
+
rv = quote_sql_values(sub, casts)
|
48
|
+
column_names = casts.map { conn.quote_column_name(_2.key?(:as) ? _2[:as] : _1) }
|
49
|
+
next "(VALUES \n(#{rv.join("),\n(")})\n ) #{name} (#{column_names.join(",") })"
|
50
|
+
|
51
|
+
when /^:(.+)_(as_select)$/i # prefix (column,...) AS ( VALUES (data::CAST, ...), ...)
|
52
|
+
name = conn.quote_column_name($1)
|
53
|
+
casts = sub.shift.transform_keys(&:to_s)
|
54
|
+
rv = quote_sql_values(sub, casts)
|
55
|
+
next "SELECT * FROM (VALUES \n(#{rv.join("),\n(")})\n ) #{name} (#{casts.keys.map { conn.quote_column_name(_1) }.join(",") })"
|
56
|
+
when /^:(.+)_(as_values)$/i # prefix (column,...) AS ( VALUES (data::CAST, ...), ...)
|
57
|
+
name = conn.quote_column_name($1)
|
58
|
+
casts = sub.shift.transform_keys(&:to_s)
|
59
|
+
rv = quote_sql_values(sub, casts)
|
60
|
+
next "#{name} (#{casts.keys.map { conn.quote_column_name(_1) }.join(",") }) AS ( VALUES \n(#{rv.join("),\n(")})\n )"
|
61
|
+
when /^:(.+)_(values)$/i
|
62
|
+
casts = sub.shift.transform_keys(&:to_sym)
|
63
|
+
rv = quote_sql_values(sub, casts)
|
64
|
+
next "VALUES \n(#{rv.join("),\n(")})\n"
|
65
|
+
when /_(LIST)$/i
|
66
|
+
next sub.map { conn.quote _1 }.join(",")
|
67
|
+
when /_(args)$/i
|
68
|
+
next sub.join(',')
|
69
|
+
when /_(raw|sql)$/i
|
70
|
+
next sub
|
71
|
+
when /_(ident|column)$/i, /table_name$/, /_?columns?$/, /column_names$/
|
72
|
+
if sub.is_a? Array
|
73
|
+
next sub.map do
|
74
|
+
_1[/^"[^"]+"\."[^"]+"$/] ? _1 : conn.quote_column_name(_1)
|
75
|
+
end.join(',')
|
76
|
+
else
|
77
|
+
next conn.quote_column_name(sub)
|
78
|
+
end
|
79
|
+
when /(?<=_)jsonb?$/i
|
80
|
+
next conn.quote(sub.to_json) + "::#{$MATCH}"
|
81
|
+
when /(?<=_)(uuid|int|text)$/i
|
82
|
+
cast = "::#{$MATCH}"
|
83
|
+
end
|
84
|
+
case sub
|
85
|
+
when Regexp
|
86
|
+
sub.to_postgres
|
87
|
+
when Array
|
88
|
+
dims = 1 # todo more dimensional Arrays
|
89
|
+
dive = ->(ary) do
|
90
|
+
ary.map { |s| conn.quote s }.join(',')
|
91
|
+
end
|
92
|
+
sub = "[#{dive.call sub}]"
|
93
|
+
cast += "[]" * dims if cast.present?
|
94
|
+
"ARRAY#{sub}#{cast}"
|
95
|
+
else
|
96
|
+
"#{conn.quote(sub)}#{cast}"
|
97
|
+
end
|
98
|
+
end
|
99
|
+
# break if options.except!(*keys).blank?
|
100
|
+
end
|
101
|
+
Arel.sql self
|
102
|
+
end
|
103
|
+
|
104
|
+
def exec
|
105
|
+
result = conn.exec_query(self)
|
106
|
+
columns = result.columns.map(&:to_sym)
|
107
|
+
result.cast_values.map do |row|
|
108
|
+
row = [row] unless row.is_a? Array
|
109
|
+
[columns, row].transpose.to_h
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
def quote_exec(**)
|
114
|
+
quote_sql(**).exec
|
115
|
+
end
|
116
|
+
|
117
|
+
module Dsql
|
118
|
+
|
119
|
+
def dsql
|
120
|
+
IO.popen(PG_FORMAT_BIN, "r+", err: "/dev/null") do |f|
|
121
|
+
f.write self
|
122
|
+
f.close_write
|
123
|
+
puts f.read
|
124
|
+
end
|
125
|
+
self
|
126
|
+
rescue
|
127
|
+
self
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
include Dsql
|
132
|
+
|
133
|
+
module String
|
134
|
+
def self.included(other)
|
135
|
+
other.include Dsql
|
136
|
+
end
|
137
|
+
|
138
|
+
def quote_sql(**)
|
139
|
+
Arel.sql(self).quote_sql(**)
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
module Relation
|
144
|
+
def quote_sql(**)
|
145
|
+
Arel.sql(to_sql).quote_sql(**)
|
146
|
+
end
|
147
|
+
|
148
|
+
def dsql
|
149
|
+
to_sql.dsql
|
150
|
+
self
|
151
|
+
end
|
152
|
+
|
153
|
+
def result
|
154
|
+
result = ApplicationRecord.connection.exec_query(to_sql)
|
155
|
+
columns = result.columns.map(&:to_sym)
|
156
|
+
result.cast_values.map do |row|
|
157
|
+
[columns, Array(row)].transpose.to_h
|
158
|
+
end
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
class QuoteSql
|
2
|
+
module Formater
|
3
|
+
PG_FORMAT_BIN = `which pg_format`.chomp.presence
|
4
|
+
|
5
|
+
def dsql
|
6
|
+
puts to_formatted_sql
|
7
|
+
nil
|
8
|
+
end
|
9
|
+
|
10
|
+
def to_formatted_sql
|
11
|
+
sql = respond_to?(:to_sql) ? to_sql : to_s
|
12
|
+
IO.popen(PG_FORMAT_BIN, "r+", err: "/dev/null") do |f|
|
13
|
+
f.write(sql)
|
14
|
+
f.close_write
|
15
|
+
f.read
|
16
|
+
end
|
17
|
+
rescue
|
18
|
+
sql
|
19
|
+
end
|
20
|
+
|
21
|
+
alias to_sqf to_formatted_sql
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,188 @@
|
|
1
|
+
class QuoteSql
|
2
|
+
class Quoter
|
3
|
+
def initialize(qsql, key, quotable)
|
4
|
+
@qsql = qsql
|
5
|
+
@key, @quotable = key, quotable
|
6
|
+
end
|
7
|
+
|
8
|
+
attr_reader :key, :quotable
|
9
|
+
|
10
|
+
def to_sql
|
11
|
+
return @quotable.call(self) if @quotable.is_a? Proc
|
12
|
+
case key.to_s
|
13
|
+
when /(?:^|(.*)_)table$/i
|
14
|
+
table
|
15
|
+
when /(?:^|(.*)_)columns?$/i
|
16
|
+
column_names
|
17
|
+
when /(?:^|(.*)_)(table_name?s?)$/i
|
18
|
+
table_name
|
19
|
+
when /(?:^|(.*)_)(column_name?s?)$/i
|
20
|
+
ident_name
|
21
|
+
when /(?:^|(.*)_)(ident|args)$/i
|
22
|
+
ident_name
|
23
|
+
when /(?:^|(.*)_)constraints?$/i
|
24
|
+
quotable.to_s
|
25
|
+
when /(?:^|(.*)_)(raw|sql)$/
|
26
|
+
quotable.to_s
|
27
|
+
when /(?:^|(.*)_)(values?)$/
|
28
|
+
values
|
29
|
+
else
|
30
|
+
quote
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
private def value(ary)
|
35
|
+
column_names = @qsql.column_names
|
36
|
+
if ary.is_a?(Hash) and column_names.present?
|
37
|
+
ary = @qsql.column_names.map do |column_name|
|
38
|
+
if ary.key? column_name&.to_sym
|
39
|
+
ary[column_name.to_sym]
|
40
|
+
elsif column_name[/^(created|updated)_at$/]
|
41
|
+
:current_timestamp
|
42
|
+
else
|
43
|
+
:default
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
"(" + ary.map do |i|
|
48
|
+
case i
|
49
|
+
when :default, :current_timestamp
|
50
|
+
next i.to_s.upcase
|
51
|
+
when Hash, Array
|
52
|
+
i = i.to_json
|
53
|
+
end
|
54
|
+
_quote(i)
|
55
|
+
end.join(",") + ")"
|
56
|
+
|
57
|
+
end
|
58
|
+
|
59
|
+
def values(item = @quotable)
|
60
|
+
case item
|
61
|
+
when Arel::Nodes::SqlLiteral
|
62
|
+
item = Arel.sql("(#{item})") unless item[/^\s*\(/] and item[/\)\s*$/]
|
63
|
+
return item
|
64
|
+
when Array
|
65
|
+
|
66
|
+
differences = item.map { _1.is_a?(Array) && _1.length }.uniq
|
67
|
+
if differences.length == 1
|
68
|
+
item.compact.map { value(_1) }.join(", ")
|
69
|
+
else
|
70
|
+
value([item])
|
71
|
+
end
|
72
|
+
when Hash
|
73
|
+
value([item])
|
74
|
+
else
|
75
|
+
return item.to_sql if item.respond_to? :to_sql
|
76
|
+
"(" + _quote(item) + ")"
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
def cast
|
81
|
+
if m = key.to_s[CASTS]
|
82
|
+
m[2..].sub(CASTS) { _1.tr("_", " ") }
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
def json?
|
87
|
+
!!key[/(^|_)(jsonb?)$/]
|
88
|
+
end
|
89
|
+
|
90
|
+
private def _quote(item = @quotable, cast = self.cast)
|
91
|
+
rv = QuoteSql.quote(item)
|
92
|
+
if cast
|
93
|
+
rv << "::#{cast}"
|
94
|
+
rv << "[]" * rv.depth if rv[/^ARRAY/]
|
95
|
+
end
|
96
|
+
rv
|
97
|
+
end
|
98
|
+
|
99
|
+
private def _quote_column_name(name, column = nil)
|
100
|
+
name, column = name.to_s.split(".") if column.nil?
|
101
|
+
rv = QuoteSql.quote_column_name(name)
|
102
|
+
return rv unless column.present?
|
103
|
+
rv + "." + QuoteSql.quote_column_name(column)
|
104
|
+
end
|
105
|
+
|
106
|
+
def quote(item = @quotable)
|
107
|
+
case item
|
108
|
+
when Arel::Nodes::SqlLiteral
|
109
|
+
return item
|
110
|
+
when Array
|
111
|
+
return _quote(item.to_json) if json?
|
112
|
+
_quote(item)
|
113
|
+
when Hash
|
114
|
+
return _quote(item.to_json) if json?
|
115
|
+
item.map do |as, item|
|
116
|
+
"#{_quote(item)} AS #{as}"
|
117
|
+
end.join(",")
|
118
|
+
else
|
119
|
+
return item.to_sql if item.respond_to? :to_sql
|
120
|
+
_quote(item)
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
def column_names(item = @quotable)
|
125
|
+
if item.respond_to?(:column_names)
|
126
|
+
item = item.column_names
|
127
|
+
elsif item.class.respond_to?(:column_names)
|
128
|
+
item = item.class.column_names
|
129
|
+
elsif item.is_a?(Array) and item[0].respond_to?(:name)
|
130
|
+
item = item.map(&:name)
|
131
|
+
end
|
132
|
+
@qsql.column_names ||= item
|
133
|
+
ident_name(item)
|
134
|
+
end
|
135
|
+
|
136
|
+
def ident_name(item = @quotable)
|
137
|
+
case item
|
138
|
+
when Array
|
139
|
+
item.map do |item|
|
140
|
+
case item
|
141
|
+
when Hash
|
142
|
+
ident_name(item)
|
143
|
+
when String, Symbol
|
144
|
+
_quote_column_name(item)
|
145
|
+
when Proc
|
146
|
+
item.call(self)
|
147
|
+
end
|
148
|
+
end.join(",")
|
149
|
+
when Hash
|
150
|
+
item.map do
|
151
|
+
case _2
|
152
|
+
when Symbol
|
153
|
+
_quote_column_name(_1, _2)
|
154
|
+
when String
|
155
|
+
"#{_2} AS #{_1}"
|
156
|
+
when Proc
|
157
|
+
item.call(self)
|
158
|
+
end
|
159
|
+
end.join(",")
|
160
|
+
else
|
161
|
+
_quote_column_name(item)
|
162
|
+
end
|
163
|
+
end
|
164
|
+
|
165
|
+
def table(item = @quotable)
|
166
|
+
@qsql.table_name ||= if item.respond_to?(:table_name)
|
167
|
+
item = item.table_name
|
168
|
+
elsif item.class.respond_to?(:table_name)
|
169
|
+
item = item.class.table_name
|
170
|
+
end
|
171
|
+
table_name(item || @qsql.table_name)
|
172
|
+
end
|
173
|
+
|
174
|
+
def table_name(item = @quotable)
|
175
|
+
case item
|
176
|
+
when Array
|
177
|
+
item.map do |item|
|
178
|
+
item.is_a?(Hash) ? table_name(item) : _quote_column_name(item)
|
179
|
+
end.join(",")
|
180
|
+
when Hash
|
181
|
+
raise NotImplementedError, "table name is a Hash"
|
182
|
+
# perhaps as ...
|
183
|
+
else
|
184
|
+
_quote_column_name(item)
|
185
|
+
end
|
186
|
+
end
|
187
|
+
end
|
188
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
class QuoteSql
|
2
|
+
module Quoting
|
3
|
+
def escape(item)
|
4
|
+
case item
|
5
|
+
when Regexp
|
6
|
+
escape_regex(item)
|
7
|
+
when Array
|
8
|
+
escape_array(item)
|
9
|
+
else
|
10
|
+
quote(item)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
def escape_array(ary)
|
15
|
+
type = nil
|
16
|
+
dive = ->(ary) do
|
17
|
+
ary.flat_map do |elem|
|
18
|
+
if elem.is_a? Array
|
19
|
+
dive[s]
|
20
|
+
elsif !elem.nil? and (type ||= elem.class.to_s) != elem.class.to_s
|
21
|
+
raise TypeError, "Array elements have to be the same kind"
|
22
|
+
else
|
23
|
+
quote elem
|
24
|
+
end
|
25
|
+
end.join(',')
|
26
|
+
end
|
27
|
+
ary = "[#{dive[ary]}]"
|
28
|
+
"ARRAY#{ary}"
|
29
|
+
end
|
30
|
+
|
31
|
+
# quote ruby regex with a postgres regex
|
32
|
+
# @argument regexp [Regex]
|
33
|
+
# @return String
|
34
|
+
def escape_regex(regexp)
|
35
|
+
# https://gist.github.com/glv/24bedd7d39f16a762528d7b30e366aa7
|
36
|
+
pregex = regexp.to_s.gsub(/^\(\?-?[mix]+:|\)$/, '')
|
37
|
+
if pregex[/[*+?}]\+|\(\?<|&&|\\k|\\g|\\p\{/]
|
38
|
+
raise RegexpError, "cant convert Regexp #{sub}"
|
39
|
+
end
|
40
|
+
pregex.gsub!(/\\h/, "[[:xdigit:]]")
|
41
|
+
pregex.gsub!(/\\H/, "[^[:xdigit:]]")
|
42
|
+
pregex.gsub!(/\?[^>]>/, '')
|
43
|
+
pregex.gsub!(/\{,/, "{0,")
|
44
|
+
pregex.gsub!(/\\z/, "\\Z")
|
45
|
+
quote(pregex)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,112 @@
|
|
1
|
+
module QuoteSql::Test
|
2
|
+
def self.all
|
3
|
+
methods(false).grep(/^test_/).each do |name|
|
4
|
+
run(name)
|
5
|
+
puts
|
6
|
+
end
|
7
|
+
|
8
|
+
end
|
9
|
+
|
10
|
+
def self.run(name)
|
11
|
+
name = name.to_s.sub(/^test_/, "")
|
12
|
+
@expected = nil
|
13
|
+
@test = send("test_#{name}")
|
14
|
+
if sql.gsub(/\s+/, "") == expected&.gsub(/\s+/, "")
|
15
|
+
STDOUT.puts name, @test.original, @test.quotes.inspect, "✅ #{expected}"
|
16
|
+
else
|
17
|
+
STDOUT.puts name, @test.inspect, sql, "❌ #{expected}"
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def self.expected(v = nil)
|
22
|
+
@expected ||= v
|
23
|
+
end
|
24
|
+
|
25
|
+
def self.sql
|
26
|
+
@test.to_sql
|
27
|
+
end
|
28
|
+
|
29
|
+
class PseudoActiveRecord
|
30
|
+
def self.table_name
|
31
|
+
"pseudo_active_records"
|
32
|
+
end
|
33
|
+
|
34
|
+
def self.column_names
|
35
|
+
%w(id column1 column2)
|
36
|
+
end
|
37
|
+
|
38
|
+
def to_qsl
|
39
|
+
"SELECT * FROM #{self.class.table_name}"
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
class << self
|
44
|
+
def test_columns_and_table_name_simple
|
45
|
+
expected Arel.sql(%(SELECT "a","b"."c" FROM "my_table"))
|
46
|
+
QuoteSql.new("SELECT %columns FROM %table_name").quote(
|
47
|
+
columns: [:a, b: :c],
|
48
|
+
table_name: "my_table"
|
49
|
+
)
|
50
|
+
end
|
51
|
+
|
52
|
+
def test_columns_and_table_name_complex
|
53
|
+
expected Arel.sql(%(SELECT "a","b"."c" FROM "table1","table2"))
|
54
|
+
QuoteSql.new("SELECT %columns FROM %table_names").quote(
|
55
|
+
columns: [:a, b: :c],
|
56
|
+
table_names: ["table1", "table2"]
|
57
|
+
)
|
58
|
+
end
|
59
|
+
|
60
|
+
def test_recursive_injects
|
61
|
+
expected Arel.sql(%(SELECT TRUE FROM "table1"))
|
62
|
+
QuoteSql.new("SELECT %raw FROM %table_names").quote(
|
63
|
+
raw: "%recurse1_raw",
|
64
|
+
recurse1_raw: "%recurse2",
|
65
|
+
recurse2: true,
|
66
|
+
table_names: "table1"
|
67
|
+
)
|
68
|
+
end
|
69
|
+
|
70
|
+
def test_values
|
71
|
+
expected Arel.sql(%(SELECT 'a text', 123, 'text' AS abc FROM "my_table"))
|
72
|
+
QuoteSql.new("SELECT %text, %{number}, %aliased_with_hash FROM %table_name").quote(
|
73
|
+
text: "a text",
|
74
|
+
number: 123,
|
75
|
+
aliased_with_hash: {
|
76
|
+
abc: "text"
|
77
|
+
},
|
78
|
+
table_name: "my_table"
|
79
|
+
)
|
80
|
+
end
|
81
|
+
|
82
|
+
def test_binds
|
83
|
+
expected Arel.sql(%(SELECT $1, $2, $1 AS get_bind_1_again FROM "my_table"))
|
84
|
+
QuoteSql.new("SELECT %bind, %bind__uuid, %bind1 AS get_bind_1_again FROM %table_name").quote(
|
85
|
+
table_name: "my_table"
|
86
|
+
)
|
87
|
+
end
|
88
|
+
|
89
|
+
def test_q3
|
90
|
+
expected Arel.sql(<<-SQL)
|
91
|
+
INSERT INTO "responses" ("id","type","task_id","index","data","parts","value","created_at","updated_at")
|
92
|
+
VALUES (NULL,TRUE,'A','[5,5]','{"a":1}'),
|
93
|
+
(1,FALSE,'B','[]','{"a":2}'),
|
94
|
+
(2,NULL,'c','[1,2,3]','{"a":3}')
|
95
|
+
ON CONFLICT (responses_task_id_index_unique) DO NOTHING;
|
96
|
+
SQL
|
97
|
+
|
98
|
+
QuoteSql.new(<<-SQL).
|
99
|
+
INSERT INTO %table (%columns) VALUES %values
|
100
|
+
ON CONFLICT (responses_task_id_index_unique) DO NOTHING;
|
101
|
+
SQL
|
102
|
+
quote(
|
103
|
+
table: Response,
|
104
|
+
values: [
|
105
|
+
[nil, true, "A", [5, 5], { a: 1 }],
|
106
|
+
[1, false, "B", [], { a: 2 }],
|
107
|
+
[2, nil, "c", [1, 2, 3], { a: 3 }]
|
108
|
+
]
|
109
|
+
)
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
data/lib/quote_sql.rb
ADDED
@@ -0,0 +1,306 @@
|
|
1
|
+
Dir.glob(__FILE__.sub(/\.rb$/, "/*.rb")).each { require(_1) unless _1[/test\.rb$/] }
|
2
|
+
|
3
|
+
# Tool to build and run SQL queries easier
|
4
|
+
#
|
5
|
+
# QuoteSql.new("SELECT %field").quote(field: "abc").to_sql
|
6
|
+
# => SELECT 'abc'
|
7
|
+
#
|
8
|
+
# QuoteSql.new("SELECT %field__text").quote(field__text: 9).to_sql
|
9
|
+
# => SELECT 9::TEXT
|
10
|
+
#
|
11
|
+
# QuoteSql.new("SELECT %columns FROM %table_name").quote(table: User).to_sql
|
12
|
+
# => SELECT "id",firstname","lastname",... FROM "users"
|
13
|
+
#
|
14
|
+
# QuoteSql.new("SELECT a,b,%raw FROM table").quote(raw: "jsonb_build_object('a', 1)").to_sql
|
15
|
+
# => SELECT "a,b,jsonb_build_object('a', 1) FROM table
|
16
|
+
#
|
17
|
+
# QuoteSql.new("SELECT %column_names FROM (%any_name) a").
|
18
|
+
# quote(any_name: User.select("%column_names").where(id: 3), column_names: [:firstname, :lastname]).to_sql
|
19
|
+
# => SELECT firstname, lastname FROM (SELECT firstname, lastname FROM users where id = 3)
|
20
|
+
#
|
21
|
+
# QuoteSql.new("INSERT INTO %table (%columns) VALUES %values ON CONFLICT (%constraint) DO NOTHING").
|
22
|
+
# quote(table: User, values: [
|
23
|
+
# {firstname: "Albert", id: 1, lastname: "Müller"},
|
24
|
+
# {lastname: "Schultz", firstname: "herbert"}
|
25
|
+
# ], constraint: :id).to_sql
|
26
|
+
# => INSERT INTO "users" ("id", "firstname", "lastname", "created_at")
|
27
|
+
# VALUES (1, 'Albert', 'Müller', CURRENT_TIMESTAMP), (DEFAULT, 'herbert', 'Schultz', CURRENT_TIMESTAMP)
|
28
|
+
# ON CONFLICT ("id") DO NOTHING
|
29
|
+
#
|
30
|
+
# QuoteSql.new("SELECT %columns").quote(columns: [:a, :"b.c", c: "jsonb_build_object('d', 1)"]).to_sql
|
31
|
+
# => SELECT "a","b"."c",jsonb_build_object('d', 1) AS c
|
32
|
+
#
|
33
|
+
# Substitution
|
34
|
+
# In the SQL matches of %foo or %{foo} or %foo_4_bar or %{foo_4_bar} the *"mixins"*
|
35
|
+
# are substituted with quoted values
|
36
|
+
# the values are looked up from the options given in the quotes method
|
37
|
+
# the mixins can be recursive, Caution! You need to take care, you can create infintive loops!
|
38
|
+
#
|
39
|
+
# Special mixins are
|
40
|
+
# - %table | %table_name | %table_names
|
41
|
+
# - %column | %columns | %column_names
|
42
|
+
# - %ident | %constraint | %constraints quoting for database columns
|
43
|
+
# - %raw | %sql inserting raw SQL
|
44
|
+
# - %value | %values creates value section for e.g. insert
|
45
|
+
# - In the right order
|
46
|
+
# - Single value => (2)
|
47
|
+
# - +Array+ => (column, column, column) n.b. has to be the correct order
|
48
|
+
# - +Array+ of +Array+ => (...),(...),(...),...
|
49
|
+
# - if the columns option is given (or implicitely by setting table)
|
50
|
+
# - +Hash+ values are ordered according to the columns option, missing values are replaced by DEFAULT
|
51
|
+
# - +Array+ of +Hash+ multiple record insert
|
52
|
+
# - %bind is replaced with the current bind sequence.
|
53
|
+
# Without appended number the first %bind => $1, the second => $2 etc.
|
54
|
+
# - %bind\\d+ => $+Integer+ e.g. %bind7 => $7
|
55
|
+
# - %bind__text => $1 and it is registered as text - this is used in prepared statements TODO
|
56
|
+
# - %key_bind__text => $1 and it is registered as text when using +Hash+ in the execute
|
57
|
+
# $1 will be mapped to the key's value in the +Hash+ TODO
|
58
|
+
#
|
59
|
+
# All can be preceded by additional letters and underscore e.g. %foo_bar_column
|
60
|
+
#
|
61
|
+
# A database typecast is added to fields ending with double underscore and a valid db data type
|
62
|
+
# with optional array dimension
|
63
|
+
#
|
64
|
+
# - %field__jsonb => adds a ::JSONB typecast to the field
|
65
|
+
# - %number_to__text => adds a ::TEXT typecast to the field
|
66
|
+
# - %array__text1 => adds a ::TEXT[] TODO
|
67
|
+
# - %array__text2 => adds a ::TEXT[][] TODO
|
68
|
+
#
|
69
|
+
# Quoting
|
70
|
+
# - Any value of the standard mixins are quoted with these exceptions
|
71
|
+
# - +Array+ are quoted as DB Arrays unless the type cast e.g. __jsonb is given
|
72
|
+
# - +Hash+ are quoted as jsonb
|
73
|
+
# - When the value responds to :to_sql or is a +Arel::Nodes::SqlLiteral+ its added as raw SQL
|
74
|
+
# - +Proc+ are executed with the +QuoteSQL::Quoter+ object as parameter and added as raw SQL
|
75
|
+
#
|
76
|
+
# Special quoting columns
|
77
|
+
# - +String+ or +Symbol+ without a dot e.g. :firstname => "firstname"
|
78
|
+
# - +String+ or +Symbol+ containing a dot e.g. "users.firstname" or => "users"."firstname"
|
79
|
+
# - +Array+
|
80
|
+
# - +String+ and +Symbols+ see above
|
81
|
+
# - +Hash+ see below
|
82
|
+
# - +Hash+ or within the +Array+
|
83
|
+
# - +Symbol+ value will become the column name e.g. {table: :column} => "table"."column"
|
84
|
+
# - +String+ value will become the expression, the key the AS {result: "SUM(*)"} => SUM(*) AS result
|
85
|
+
# - +Proc+ are executed with the +QuoteSQL::Quoter+ object as parameter and added as raw SQL
|
86
|
+
#
|
87
|
+
class QuoteSql
|
88
|
+
DATA_TYPES_RE = %w(
|
89
|
+
(?:small|big)(?:int|serial)
|
90
|
+
bit bool(?:ean)? box bytea cidr circle date
|
91
|
+
(?:date|int[48]|num|ts(?:tz)?)(?:multi)?range
|
92
|
+
macaddr8?
|
93
|
+
jsonb?
|
94
|
+
ts(?:query|vector)
|
95
|
+
float[48] (?:int|serial)[248]?
|
96
|
+
double_precision inet
|
97
|
+
integer line lseg money path pg_lsn
|
98
|
+
pg_snapshot point polygon real text timestamptz timetz
|
99
|
+
txid_snapshot uuid xml
|
100
|
+
(bit_varying|varbit|character|char|character varying|varchar)(_\\(\\d+\\))?
|
101
|
+
(numeric|decimal)(_\\(\d+_\d+\\))?
|
102
|
+
interval(_(YEAR|MONTH|DAY|HOUR|MINUTE|SECOND|YEAR_TO_MONTH|DAY_TO_HOUR|DAY_TO_MINUTE|DAY_TO_SECOND|HOUR_TO_MINUTE|HOUR_TO_SECOND|MINUTE_TO_SECOND))?(_\\(\d+\\))?
|
103
|
+
time(stamp)?(_\\(\d+\\))?(_with(out)?_time_zone)?
|
104
|
+
).join("|")
|
105
|
+
|
106
|
+
CASTS = Regexp.new("__(#{DATA_TYPES_RE})$", "i")
|
107
|
+
|
108
|
+
def self.conn
|
109
|
+
raise ArgumentError, "You need to define a database connection function"
|
110
|
+
end
|
111
|
+
|
112
|
+
def self.db_connector=(conn)
|
113
|
+
Connector.set(conn)
|
114
|
+
end
|
115
|
+
|
116
|
+
def initialize(sql = nil)
|
117
|
+
@original = sql.respond_to?(:to_sql) ? sql.to_sql : sql.to_s
|
118
|
+
@sql = @original.dup
|
119
|
+
@quotes = {}
|
120
|
+
@resolved = {}
|
121
|
+
@binds = []
|
122
|
+
end
|
123
|
+
|
124
|
+
attr_reader :sql, :quotes, :original, :binds
|
125
|
+
attr_writer :table_name, :column_names
|
126
|
+
|
127
|
+
def table_name
|
128
|
+
return @table_name if @table_name
|
129
|
+
return unless table = @quote&.dig(:table)
|
130
|
+
@table_name = table.respond_to?(:table_name) ? table.table_name : table.to_s
|
131
|
+
end
|
132
|
+
|
133
|
+
def column_names
|
134
|
+
return @column_names if @column_names
|
135
|
+
return unless columns = @quote&.dig(:columns)
|
136
|
+
@column_names = if columns[0].is_a? String
|
137
|
+
columns
|
138
|
+
else
|
139
|
+
columns.map(&:name)
|
140
|
+
end.map(&:to_s)
|
141
|
+
end
|
142
|
+
|
143
|
+
# Add quotes keys are symbolized
|
144
|
+
def quote(quotes1 = {}, **quotes2)
|
145
|
+
quotes = @quotes.merge(quotes1, quotes2).transform_keys(&:to_sym)
|
146
|
+
if table = quotes.delete(:table)
|
147
|
+
columns = quotes.delete(:columns) || table.columns
|
148
|
+
end
|
149
|
+
@quotes = { table:, columns:, **quotes }
|
150
|
+
self
|
151
|
+
end
|
152
|
+
|
153
|
+
class Error < ::RuntimeError
|
154
|
+
def initialize(quote_sql)
|
155
|
+
@object = quote_sql
|
156
|
+
end
|
157
|
+
|
158
|
+
def message
|
159
|
+
super + %Q@<QuoteSql #{@object.original.inspect} #{@object.errors.inspect}>@
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
def to_sql
|
164
|
+
mixin!
|
165
|
+
raise Error.new(self) if errors?
|
166
|
+
return Arel.sql @sql if defined? Arel
|
167
|
+
@sql
|
168
|
+
end
|
169
|
+
|
170
|
+
def result(binds = [], prepare: false, async: false)
|
171
|
+
sql = to_sql
|
172
|
+
if binds.present? and sql.scan(/(?<=\$)\d+/).map(&:to_i).max + 1 != binds.length
|
173
|
+
raise ArgumentError, "Wrong number of binds"
|
174
|
+
end
|
175
|
+
_exec(sql, binds, prepare: false, async: false)
|
176
|
+
rescue => exc
|
177
|
+
STDERR.puts exc.sql
|
178
|
+
raise exc
|
179
|
+
end
|
180
|
+
|
181
|
+
alias exec result
|
182
|
+
|
183
|
+
def prepare(name)
|
184
|
+
sql = to_sql
|
185
|
+
raise ArguemntError, "binds not all casted e.g. %bind__CAST" if @binds.reject.any?
|
186
|
+
name = quote_column_name(name)
|
187
|
+
_exec_query("PREPARE #{name} (#{@binds.join(',')}) AS #{sql}")
|
188
|
+
@prepare_name = name
|
189
|
+
end
|
190
|
+
|
191
|
+
|
192
|
+
# Executes a prepared statement
|
193
|
+
# Processes in batches records
|
194
|
+
# returns the array of the results depending on RETURNING is in the query
|
195
|
+
#
|
196
|
+
# execute([1, "a", true, nil], ...)
|
197
|
+
#
|
198
|
+
# execute({ id: 1, text: "a", bool: true, know: nil}, ...)
|
199
|
+
#
|
200
|
+
# execute([1, "a", true, nil], ... batch: 500)
|
201
|
+
# # set the batch size of 500
|
202
|
+
#
|
203
|
+
# execute([1, "a", true, nil], ... batch: falss)
|
204
|
+
# # processes all at once
|
205
|
+
def execute(*records, batch: 1000)
|
206
|
+
sql = "EXECUTE #{@prepare_name}(#{(1..@binds.length).map { "$#{_1}" }.join(",")})"
|
207
|
+
records.map! do |record|
|
208
|
+
if record.is_a?(Hash)
|
209
|
+
raise NotImplementedError, "record hash not yet implemented"
|
210
|
+
else
|
211
|
+
record = Array(record)
|
212
|
+
end
|
213
|
+
if @binds.length != record.length
|
214
|
+
next RuntimeError.new("binds are not equal arguments, #{record.inspect}")
|
215
|
+
end
|
216
|
+
_exec(sql, record, prepare: false, async: false)
|
217
|
+
end
|
218
|
+
end
|
219
|
+
|
220
|
+
def reset
|
221
|
+
@sql = @original
|
222
|
+
end
|
223
|
+
|
224
|
+
def errors
|
225
|
+
@quotes.to_h do |k, v|
|
226
|
+
r = @resolved[k]
|
227
|
+
next [nil, nil] unless r.nil? or r.is_a?(Exception)
|
228
|
+
[k, "#{@quotes[k].inspect} => #{v.inspect}"]
|
229
|
+
end.compact
|
230
|
+
end
|
231
|
+
|
232
|
+
def errors?
|
233
|
+
@resolved.any? { _2.is_a? Exception }
|
234
|
+
end
|
235
|
+
|
236
|
+
MIXIN_RE = /(%\{?([a-z][a-z0-9_]*)}|%([a-z][a-z0-9_]*)\b)/im
|
237
|
+
|
238
|
+
def key_matches
|
239
|
+
@sql.scan(MIXIN_RE).map do |full, *key|
|
240
|
+
key = key.compact[0]
|
241
|
+
[full, key, @quotes.key?(key.to_sym)]
|
242
|
+
end
|
243
|
+
end
|
244
|
+
|
245
|
+
def mixin!
|
246
|
+
unresolved = Set.new(key_matches.map(&:second))
|
247
|
+
last_unresolved = Set.new
|
248
|
+
loop do
|
249
|
+
s = StringScanner.new(@sql)
|
250
|
+
sql = ""
|
251
|
+
key_matches.each do |key_match, key, has_quote|
|
252
|
+
s.scan_until(/(.*?)#{key_match}([a-z0-9_]*)/im)
|
253
|
+
matched, pre, post = s.matched, s[1], s[2]
|
254
|
+
if m = key.match(/^bind(\d+)?(?:#{CASTS})?$/im)
|
255
|
+
if m[2].present?
|
256
|
+
cast = m[2].tr("_", " ")
|
257
|
+
end
|
258
|
+
if m[1].present?
|
259
|
+
bind_num = m[1].to_i
|
260
|
+
@binds[bind_num - 1] ||= cast
|
261
|
+
raise "cast #{bind_num} already set to #{@binds[bind_num - 1]}" unless @binds[bind_num - 1] == cast
|
262
|
+
else
|
263
|
+
@binds << cast
|
264
|
+
bind_num = @binds.length
|
265
|
+
end
|
266
|
+
matched = "#{pre}$#{bind_num}#{post}"
|
267
|
+
elsif has_quote
|
268
|
+
quoted = quoter(key)
|
269
|
+
unresolved.delete key
|
270
|
+
if (i = quoted.scan MIXIN_RE).present?
|
271
|
+
unresolved += i.map(&:last)
|
272
|
+
end
|
273
|
+
matched = "#{pre}#{quoted}#{post}"
|
274
|
+
end
|
275
|
+
rescue TypeError
|
276
|
+
ensure
|
277
|
+
sql << matched.to_s
|
278
|
+
end
|
279
|
+
@sql = sql + s.rest
|
280
|
+
break if unresolved.empty?
|
281
|
+
break if unresolved == last_unresolved
|
282
|
+
last_unresolved = unresolved.dup
|
283
|
+
end
|
284
|
+
self
|
285
|
+
end
|
286
|
+
|
287
|
+
def quoter(key)
|
288
|
+
quoter = @resolved[key.to_sym] = Quoter.new(self, key, @quotes[key.to_sym])
|
289
|
+
quoter.to_sql
|
290
|
+
rescue TypeError => exc
|
291
|
+
@resolved[key.to_sym] = exc
|
292
|
+
raise exc
|
293
|
+
end
|
294
|
+
|
295
|
+
extend Quoting
|
296
|
+
|
297
|
+
end
|
298
|
+
|
299
|
+
QuoteSql.include QuoteSql::Formater
|
300
|
+
|
301
|
+
class Array
|
302
|
+
def depth
|
303
|
+
select { _1.is_a?(Array) }.map { _1.depth.to_i + 1 }.max || 1
|
304
|
+
end
|
305
|
+
end
|
306
|
+
|
metadata
ADDED
@@ -0,0 +1,55 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: quote-sql
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Martin Kufner
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2024-02-23 00:00:00.000000000 Z
|
12
|
+
dependencies: []
|
13
|
+
description: 'QuoteSql helps you creating SQL queries and proper quoting especially
|
14
|
+
with advanced queries.
|
15
|
+
|
16
|
+
'
|
17
|
+
email: martin.kufner@quiz.baby
|
18
|
+
executables: []
|
19
|
+
extensions: []
|
20
|
+
extra_rdoc_files: []
|
21
|
+
files:
|
22
|
+
- README.md
|
23
|
+
- lib/quote_sql.rb
|
24
|
+
- lib/quote_sql/connector.rb
|
25
|
+
- lib/quote_sql/connector/active_record_base.rb
|
26
|
+
- lib/quote_sql/deprecated.rb
|
27
|
+
- lib/quote_sql/extension.rb
|
28
|
+
- lib/quote_sql/formater.rb
|
29
|
+
- lib/quote_sql/quoter.rb
|
30
|
+
- lib/quote_sql/quoting.rb
|
31
|
+
- lib/quote_sql/test.rb
|
32
|
+
homepage: https://github.com/martin-kufner/quote-sql
|
33
|
+
licenses:
|
34
|
+
- MIT
|
35
|
+
metadata: {}
|
36
|
+
post_install_message:
|
37
|
+
rdoc_options: []
|
38
|
+
require_paths:
|
39
|
+
- lib
|
40
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
41
|
+
requirements:
|
42
|
+
- - ">="
|
43
|
+
- !ruby/object:Gem::Version
|
44
|
+
version: '0'
|
45
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
46
|
+
requirements:
|
47
|
+
- - ">="
|
48
|
+
- !ruby/object:Gem::Version
|
49
|
+
version: '0'
|
50
|
+
requirements: []
|
51
|
+
rubygems_version: 3.4.19
|
52
|
+
signing_key:
|
53
|
+
specification_version: 4
|
54
|
+
summary: Tool to build and run SQL queries easier
|
55
|
+
test_files: []
|