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 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,14 @@
1
+ class QuoteSql
2
+ module Extension
3
+ def self.included(other)
4
+ other.include QuoteSql::Formater
5
+ end
6
+
7
+ def quote_sql(**)
8
+ QuoteSql.new(self).quote(**)
9
+ end
10
+
11
+ alias qsql quote_sql
12
+ end
13
+ end
14
+
@@ -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: []