json2sql 1.0.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 40d7668af9e81534fea68e0745ccb82901d144b1cd3f8851999442262f6e3167
4
+ data.tar.gz: d12e2089964c6eecfbd752025f492d56f78bdf72102bb1879d0e57056765ae2a
5
+ SHA512:
6
+ metadata.gz: 1bdf680bb7009a436cbe5fcdfa70dd6b69036259f61c26b111b72ed3e8d07ed52528b2ffda79d15db4e0a7a6887cfea2e0c99ca504d421ac06c479eb4ac1a2ed
7
+ data.tar.gz: 7f5187f7f7d1bc36284d7fb11bef6273dba90e4f7deb78cf286909831bd0e62a6a02e1354e6ed193e2f7c631f04ee72f87f16ff41c2a37af2a2e79b9b6adb4f4
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tiago da Silva
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,20 @@
1
+ module Json2sql
2
+ # Builds a DELETE FROM statement for a single table.
3
+ #
4
+ # Input Hash:
5
+ # "and" => { ... } – WHERE conditions (required to avoid deleting all rows)
6
+ # "or" => { ... } – WHERE conditions (OR)
7
+ class DeleteModel
8
+ def initialize(sql, table, relation)
9
+ @sql = sql
10
+ @table = table.to_s
11
+ @relation = relation
12
+ end
13
+
14
+ def build(params)
15
+ @sql << "DELETE FROM "
16
+ @sql << Sanitizer.keyword_wrap(@table)
17
+ WhereModel.new(@sql, @table, @relation).build(params)
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,42 @@
1
+ module Json2sql
2
+ # Builds one or more DELETE statements from a Hash of table → params.
3
+ #
4
+ # Usage (single deletion):
5
+ # sql = Json2sql::DeleteRunner.build(
6
+ # "users" => { "and" => { "id" => 42 } }
7
+ # )
8
+ #
9
+ # Usage (multiple deletions — value is an Array):
10
+ # sql = Json2sql::DeleteRunner.build(
11
+ # "sessions" => [
12
+ # { "and" => { "user_id" => 1 } },
13
+ # { "and" => { "user_id" => 2 } }
14
+ # ]
15
+ # )
16
+ class DeleteRunner
17
+ def self.build(input)
18
+ input = Json2sql.normalize(input)
19
+ sql = +""
20
+ relation = WhereRelation.none("")
21
+
22
+ input.each do |table, value|
23
+ tbl = table.to_s
24
+
25
+ case value
26
+ when Hash
27
+ DeleteModel.new(sql, tbl, relation).build(value)
28
+ sql << ";\n"
29
+ when Array
30
+ value.each do |item|
31
+ next unless item.is_a?(Hash)
32
+
33
+ DeleteModel.new(sql, tbl, relation).build(item)
34
+ sql << ";\n"
35
+ end
36
+ end
37
+ end
38
+
39
+ sql
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,56 @@
1
+ module Json2sql
2
+ # Builds an INSERT INTO statement for a single table.
3
+ #
4
+ # Input Hash:
5
+ # "columns" => { "col_name" => value, ... }
6
+ #
7
+ # Values:
8
+ # Integer / Float → inserted as raw numbers
9
+ # String → wrapped in single quotes with SQL escaping
10
+ class InsertModel
11
+ def initialize(sql, table)
12
+ @sql = sql
13
+ @table = table.to_s
14
+ end
15
+
16
+ def build(params)
17
+ @sql << "INSERT INTO "
18
+ @sql << Sanitizer.keyword_wrap(@table)
19
+ @sql << " ("
20
+ build_columns(params)
21
+ @sql << ") VALUES ("
22
+ build_values(params)
23
+ @sql << ")"
24
+ end
25
+
26
+ private
27
+
28
+ def build_columns(params)
29
+ columns = params["columns"]
30
+ return unless columns.is_a?(Hash)
31
+
32
+ separator = false
33
+ columns.each_key do |key|
34
+ @sql << ", " if separator
35
+ separator = true
36
+ @sql << Sanitizer.keyword_wrap(key.to_s)
37
+ end
38
+ end
39
+
40
+ def build_values(params)
41
+ columns = params["columns"]
42
+ return unless columns.is_a?(Hash)
43
+
44
+ separator = false
45
+ columns.each_value do |value|
46
+ @sql << ", " if separator
47
+ separator = true
48
+
49
+ case value
50
+ when Integer, Float then @sql << value.to_s
51
+ when String then @sql << Sanitizer.value_wrap(value)
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,41 @@
1
+ module Json2sql
2
+ # Builds one or more INSERT statements from a Hash of table → params.
3
+ #
4
+ # Usage (single row):
5
+ # sql = Json2sql::InsertRunner.build(
6
+ # "users" => { "columns" => { "name" => "João", "email" => "j@x.com" } }
7
+ # )
8
+ #
9
+ # Usage (multiple rows — value is an Array):
10
+ # sql = Json2sql::InsertRunner.build(
11
+ # "tags" => [
12
+ # { "columns" => { "name" => "ruby" } },
13
+ # { "columns" => { "name" => "rails" } }
14
+ # ]
15
+ # )
16
+ class InsertRunner
17
+ def self.build(input)
18
+ input = Json2sql.normalize(input)
19
+ sql = +""
20
+
21
+ input.each do |table, value|
22
+ tbl = table.to_s
23
+
24
+ case value
25
+ when Hash
26
+ InsertModel.new(sql, tbl).build(value)
27
+ sql << ";\n"
28
+ when Array
29
+ value.each do |item|
30
+ next unless item.is_a?(Hash)
31
+
32
+ InsertModel.new(sql, tbl).build(item)
33
+ sql << ";\n"
34
+ end
35
+ end
36
+ end
37
+
38
+ sql
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,49 @@
1
+ module Json2sql
2
+ module Sanitizer
3
+ # Characters stripped from SQL identifiers (table/column names).
4
+ KEYWORD_DANGEROUS = /[ `;"'\\]/
5
+
6
+ # Removes dangerous characters from an identifier string.
7
+ def self.keyword(input)
8
+ input.to_s.gsub(KEYWORD_DANGEROUS, "")
9
+ end
10
+
11
+ # Escapes a value string for safe embedding between SQL quotes.
12
+ # ' → '' and \ → \\
13
+ def self.value(input)
14
+ input.to_s.gsub("\\", "\\\\\\\\").gsub("'", "''")
15
+ end
16
+
17
+ # Wraps an identifier in the given quote character (default: backtick).
18
+ # Dangerous characters inside the identifier are stripped.
19
+ def self.keyword_wrap(input, wrap = "`")
20
+ "#{wrap}#{keyword(input)}#{wrap}"
21
+ end
22
+
23
+ # Wraps a value in the given quote character (default: single-quote).
24
+ # Single quotes and backslashes inside the value are escaped.
25
+ def self.value_wrap(input, wrap = "'")
26
+ "#{wrap}#{value(input)}#{wrap}"
27
+ end
28
+
29
+ # Converts a JSON path reference (e.g. "$.users.id") into a
30
+ # backtick-quoted SQL reference (e.g. "`users`.`id`").
31
+ # Strips the leading "$." and splits on ".".
32
+ def self.reference(input)
33
+ str = input.to_s[2..] # strip leading "$."
34
+ result = +"`"
35
+ str.each_char do |c|
36
+ case c
37
+ when "."
38
+ result << "`.`"
39
+ when " ", "`", ";", '"', "'", "\\"
40
+ # skip dangerous characters
41
+ else
42
+ result << c
43
+ end
44
+ end
45
+ result << "`"
46
+ result
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,222 @@
1
+ module Json2sql
2
+ # Builds a SELECT SQL statement for a single table.
3
+ #
4
+ # Input Hash keys (all optional):
5
+ # "columns" => ["id", "name", ...] – columns to SELECT
6
+ # "where" => { "and" => {...} } – WHERE conditions (see WhereModel)
7
+ # "and" => {...} – shorthand for top-level AND WHERE
8
+ # "or" => {...} – shorthand for top-level OR WHERE
9
+ # "order" => { "col" => "asc" } – ORDER BY
10
+ # "limit" => 10 – LIMIT
11
+ # "offset" => 20 – OFFSET
12
+ # "options" => ["total"] – wrap response with data/total JSON
13
+ # "children" => { "table" => {...} } – nested child arrays
14
+ # "parents" => { "table" => {...} } – nested parent objects
15
+ class SelectModel
16
+ def initialize(sql, table, relation)
17
+ @sql = sql
18
+ @table = table.to_s
19
+ @relation = relation
20
+ end
21
+
22
+ # SELECT COUNT(*) AS `table` FROM `table` WHERE ...
23
+ def build_query_count(params)
24
+ @sql << "SELECT COUNT(*) AS "
25
+ @sql << Sanitizer.keyword_wrap(@table)
26
+ @sql << " FROM "
27
+ @sql << Sanitizer.keyword_wrap(@table)
28
+ WhereModel.new(@sql, @table, @relation).build(params)
29
+ end
30
+
31
+ # Plain SELECT col1, col2 FROM `table` WHERE ... ORDER BY ... LIMIT ... OFFSET ...
32
+ def build_query_default(params)
33
+ @sep = false
34
+ @sql << "SELECT "
35
+ build_columns_default(params)
36
+ @sql << " FROM "
37
+ @sql << Sanitizer.keyword_wrap(@table)
38
+ WhereModel.new(@sql, @table, @relation).build(params)
39
+ build_order(params)
40
+ build_limit(params)
41
+ build_offset(params)
42
+ end
43
+
44
+ # SELECT JSON_ARRAYAGG(JSON_OBJECT(...)) AS `table`
45
+ # FROM LATERAL (SELECT * FROM `table` WHERE ... ORDER ... LIMIT ...) AS `table`
46
+ def build_query_array(params)
47
+ @sep = false
48
+ @sql << "SELECT JSON_ARRAYAGG(JSON_OBJECT("
49
+ build_columns_json(params)
50
+ build_columns_array(params)
51
+ build_columns_object(params)
52
+ @sql << ")) AS "
53
+ @sql << Sanitizer.keyword_wrap(@table)
54
+ @sql << " FROM LATERAL (SELECT * FROM "
55
+ @sql << Sanitizer.keyword_wrap(@table)
56
+ WhereModel.new(@sql, @table, @relation).build(params)
57
+ build_order(params)
58
+ build_limit(params)
59
+ build_offset(params)
60
+ @sql << ") AS "
61
+ @sql << Sanitizer.keyword_wrap(@table)
62
+ end
63
+
64
+ # SELECT JSON_OBJECT(...) AS `table`
65
+ # FROM LATERAL (SELECT * FROM `table` WHERE ...) AS `table`
66
+ def build_query_object(params)
67
+ @sep = false
68
+ @sql << "SELECT JSON_OBJECT("
69
+ build_columns_json(params)
70
+ build_columns_array(params)
71
+ build_columns_object(params)
72
+ @sql << ") AS "
73
+ @sql << Sanitizer.keyword_wrap(@table)
74
+ @sql << " FROM LATERAL (SELECT * FROM "
75
+ @sql << Sanitizer.keyword_wrap(@table)
76
+ WhereModel.new(@sql, @table, @relation).build(params)
77
+ build_order(params)
78
+ build_limit(params)
79
+ build_offset(params)
80
+ @sql << ") AS "
81
+ @sql << Sanitizer.keyword_wrap(@table)
82
+ end
83
+
84
+ # Smart dispatcher:
85
+ # - no options → build_query_array
86
+ # - options includes "total" → wraps with JSON_OBJECT('data', ..., 'total', COUNT(*))
87
+ def build_query_options(params)
88
+ options = params["options"]
89
+
90
+ unless options.is_a?(Array) && !options.empty?
91
+ build_query_array(params)
92
+ return
93
+ end
94
+
95
+ total = options.include?("total")
96
+
97
+ @sql << "SELECT JSON_OBJECT('data', ("
98
+ build_query_array(params)
99
+ @sql << ")"
100
+
101
+ if total
102
+ @sql << ", 'total', ("
103
+ build_query_count(params)
104
+ @sql << ")"
105
+ end
106
+
107
+ @sql << ")"
108
+ end
109
+
110
+ private
111
+
112
+ # @sep is a shared separator flag used by build_columns_* to coordinate
113
+ # comma placement across multiple calls within a single query build.
114
+
115
+ # Appends plain column references: `table`.`col`, `table`.`col2`, ...
116
+ def build_columns_default(params)
117
+ columns = params["columns"]
118
+ return unless columns.is_a?(Array)
119
+
120
+ columns.each do |column|
121
+ next unless column.is_a?(String) || column.is_a?(Symbol)
122
+
123
+ @sql << ", " if @sep
124
+ @sep = true
125
+ @sql << Sanitizer.keyword_wrap(@table) << "."
126
+ @sql << Sanitizer.keyword_wrap(column.to_s)
127
+ end
128
+ end
129
+
130
+ # Appends JSON key-value pairs for columns: 'col', `table`.`col`, ...
131
+ def build_columns_json(params)
132
+ columns = params["columns"]
133
+ return unless columns.is_a?(Array)
134
+
135
+ columns.each do |column|
136
+ next unless column.is_a?(String) || column.is_a?(Symbol)
137
+
138
+ @sql << ", " if @sep
139
+ @sep = true
140
+ col = column.to_s
141
+ @sql << Sanitizer.keyword_wrap(col, "'")
142
+ @sql << ", "
143
+ @sql << Sanitizer.keyword_wrap(@table) << "."
144
+ @sql << Sanitizer.keyword_wrap(col)
145
+ end
146
+ end
147
+
148
+ # Appends nested child arrays (subquery → JSON_ARRAYAGG).
149
+ # Uses WhereRelation::PARENT because child table references parent.
150
+ def build_columns_array(params)
151
+ children = params["children"]
152
+ return unless children.is_a?(Hash)
153
+
154
+ relation = WhereRelation.parent(@table)
155
+
156
+ children.each do |key, value|
157
+ next unless value.is_a?(Hash)
158
+
159
+ @sql << ", " if @sep
160
+ @sep = true
161
+ tbl = key.to_s
162
+ @sql << Sanitizer.keyword_wrap(tbl, "'")
163
+ @sql << ", ("
164
+ SelectModel.new(@sql, tbl, relation).build_query_options(value)
165
+ @sql << ")"
166
+ end
167
+ end
168
+
169
+ # Appends nested parent objects (subquery → JSON_OBJECT, single row).
170
+ # Uses WhereRelation::CHILD because parent table is referenced from child.
171
+ def build_columns_object(params)
172
+ parents = params["parents"]
173
+ return unless parents.is_a?(Hash)
174
+
175
+ relation = WhereRelation.child(@table)
176
+
177
+ parents.each do |key, value|
178
+ next unless value.is_a?(Hash)
179
+
180
+ @sql << ", " if @sep
181
+ @sep = true
182
+ tbl = key.to_s
183
+ @sql << Sanitizer.keyword_wrap(tbl, "'")
184
+ @sql << ", ("
185
+ SelectModel.new(@sql, tbl, relation).build_query_object(value)
186
+ @sql << ")"
187
+ end
188
+ end
189
+
190
+ def build_order(params)
191
+ order = params["order"]
192
+ return unless order.is_a?(Hash) && !order.empty?
193
+
194
+ @sql << " ORDER BY "
195
+ glue = false
196
+
197
+ order.each do |key, value|
198
+ @sql << ", " if glue
199
+ glue = true
200
+
201
+ column = key.to_s
202
+ @sql << Sanitizer.keyword_wrap(@table) << "."
203
+ @sql << Sanitizer.keyword_wrap(column)
204
+
205
+ case value.to_s.downcase
206
+ when "asc" then @sql << " ASC"
207
+ when "desc" then @sql << " DESC"
208
+ end
209
+ end
210
+ end
211
+
212
+ def build_limit(params)
213
+ limit = params["limit"]
214
+ @sql << " LIMIT #{limit}" if limit.is_a?(Integer)
215
+ end
216
+
217
+ def build_offset(params)
218
+ offset = params["offset"]
219
+ @sql << " OFFSET #{offset}" if offset.is_a?(Integer)
220
+ end
221
+ end
222
+ end
@@ -0,0 +1,44 @@
1
+ module Json2sql
2
+ # Builds a top-level SELECT statement from a Hash of table → params.
3
+ #
4
+ # Usage:
5
+ # sql = Json2sql::SelectRunner.build(
6
+ # "users" => {
7
+ # "columns" => ["id", "name", "email"],
8
+ # "and" => { "active" => 1, "role" => { "in" => [1, 2] } },
9
+ # "order" => { "created_at" => "desc" },
10
+ # "limit" => 20,
11
+ # "offset" => 0,
12
+ # "options" => ["total"]
13
+ # }
14
+ # )
15
+ #
16
+ # Output wraps every table in JSON_OBJECT so the client receives a single
17
+ # JSON document:
18
+ # SELECT JSON_OBJECT('users', (...));
19
+ class SelectRunner
20
+ def self.build(input)
21
+ input = Json2sql.normalize(input)
22
+ sql = +""
23
+ separator = false
24
+ relation = WhereRelation.none("")
25
+
26
+ sql << "SELECT JSON_OBJECT("
27
+
28
+ input.each do |table, value|
29
+ next unless value.is_a?(Hash)
30
+
31
+ sql << ", " if separator
32
+ separator = true
33
+
34
+ sql << Sanitizer.keyword_wrap(table.to_s, "'")
35
+ sql << ", ("
36
+ SelectModel.new(sql, table.to_s, relation).build_query_options(value)
37
+ sql << ")"
38
+ end
39
+
40
+ sql << ");\n"
41
+ sql
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,48 @@
1
+ module Json2sql
2
+ # Builds an UPDATE statement for a single table.
3
+ #
4
+ # Input Hash:
5
+ # "columns" => { "col" => value, ... } – columns to SET
6
+ # "and" => { ... } – WHERE conditions
7
+ # "or" => { ... } – WHERE conditions (OR)
8
+ #
9
+ # Value types follow the same rules as InsertModel.
10
+ class UpdateModel
11
+ def initialize(sql, table, relation)
12
+ @sql = sql
13
+ @table = table.to_s
14
+ @relation = relation
15
+ end
16
+
17
+ def build(params)
18
+ @sql << "UPDATE "
19
+ @sql << Sanitizer.keyword_wrap(@table)
20
+ @sql << " SET "
21
+ build_columns(params)
22
+ WhereModel.new(@sql, @table, @relation).build(params)
23
+ end
24
+
25
+ private
26
+
27
+ def build_columns(params)
28
+ columns = params["columns"]
29
+ return unless columns.is_a?(Hash)
30
+
31
+ separator = false
32
+ columns.each do |key, value|
33
+ @sql << ", " if separator
34
+ separator = true
35
+
36
+ column = key.to_s
37
+ @sql << Sanitizer.keyword_wrap(@table) << "."
38
+ @sql << Sanitizer.keyword_wrap(column)
39
+ @sql << " = "
40
+
41
+ case value
42
+ when Integer, Float then @sql << value.to_s
43
+ when String then @sql << Sanitizer.value_wrap(value)
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,45 @@
1
+ module Json2sql
2
+ # Builds one or more UPDATE statements from a Hash of table → params.
3
+ #
4
+ # Usage (single row):
5
+ # sql = Json2sql::UpdateRunner.build(
6
+ # "users" => {
7
+ # "columns" => { "name" => "Maria", "updated_at" => "2026-04-12" },
8
+ # "and" => { "id" => 42 }
9
+ # }
10
+ # )
11
+ #
12
+ # Usage (multiple rows — value is an Array):
13
+ # sql = Json2sql::UpdateRunner.build(
14
+ # "settings" => [
15
+ # { "columns" => { "value" => "dark" }, "and" => { "key" => "theme" } },
16
+ # { "columns" => { "value" => "en" }, "and" => { "key" => "lang" } }
17
+ # ]
18
+ # )
19
+ class UpdateRunner
20
+ def self.build(input)
21
+ input = Json2sql.normalize(input)
22
+ sql = +""
23
+ relation = WhereRelation.none("")
24
+
25
+ input.each do |table, value|
26
+ tbl = table.to_s
27
+
28
+ case value
29
+ when Hash
30
+ UpdateModel.new(sql, tbl, relation).build(value)
31
+ sql << ";\n"
32
+ when Array
33
+ value.each do |item|
34
+ next unless item.is_a?(Hash)
35
+
36
+ UpdateModel.new(sql, tbl, relation).build(item)
37
+ sql << ";\n"
38
+ end
39
+ end
40
+ end
41
+
42
+ sql
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Json2sql
4
+ VERSION = "1.0.0"
5
+ end
@@ -0,0 +1,262 @@
1
+ module Json2sql
2
+ # Builds a SQL WHERE clause from a Hash describing the conditions.
3
+ #
4
+ # Input structure mirrors the JSON format used in the C++ backend:
5
+ #
6
+ # {
7
+ # "and" => {
8
+ # "name" => "john", # implicit LIKE '%john%'
9
+ # "age" => 30, # implicit equality
10
+ # "status" => { "in" => [1,2] },
11
+ # "score" => { ">=" => 4.5 },
12
+ # "col" => { "null" => true }, # IS NULL / IS NOT NULL
13
+ # "ref" => { "=" => "$.table.col" } # column reference
14
+ # },
15
+ # "or" => { ... }
16
+ # }
17
+ #
18
+ # Supported operators: = < > <= >= != <>
19
+ # in !in like !like
20
+ # String pseudo-actions: contains (LIKE %v%), first (LIKE v%), last (LIKE %v)
21
+ class WhereModel
22
+ def initialize(sql, table, relation)
23
+ @sql = sql
24
+ @table = table.to_s
25
+ @relation = relation
26
+ end
27
+
28
+ def build(params)
29
+ has_relation = @relation.kind != WhereRelation::NONE
30
+ has_where_and = params["and"].is_a?(Hash)
31
+ has_where_or = params["or"].is_a?(Hash)
32
+
33
+ return unless has_relation || has_where_and || has_where_or
34
+
35
+ @sql << " WHERE "
36
+
37
+ if has_relation
38
+ @relation.build_table_relation(@sql, @table)
39
+ @sql << " AND " if has_where_and || has_where_or
40
+ end
41
+
42
+ if has_where_and
43
+ build_column_group(params["and"], " AND ")
44
+ return
45
+ end
46
+
47
+ if has_where_or
48
+ build_column_group(params["or"], " OR ")
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ # -------------------------------------------------------------------------
55
+ # Group level
56
+ # -------------------------------------------------------------------------
57
+
58
+ def build_column_group(params, scope)
59
+ @sql << "("
60
+ glue = false
61
+
62
+ params.each do |key, value|
63
+ @sql << scope if glue
64
+ glue = true
65
+
66
+ build_column_types(value, scope, key.to_s)
67
+ end
68
+
69
+ @sql << ")"
70
+ end
71
+
72
+ # Dispatch by Ruby type of the value.
73
+ def build_column_types(params, scope, column)
74
+ case params
75
+ when TrueClass, FalseClass
76
+ build_action_types(params, column, "=")
77
+ when Integer
78
+ build_action_types(params, column, "=")
79
+ when String
80
+ build_action_types(params, column, "contains")
81
+ when Hash
82
+ if column == "and"
83
+ build_column_group(params, " AND ")
84
+ elsif column == "or"
85
+ build_column_group(params, " OR ")
86
+ else
87
+ build_action_group(params, scope, column)
88
+ end
89
+ end
90
+ end
91
+
92
+ # -------------------------------------------------------------------------
93
+ # Action level
94
+ # -------------------------------------------------------------------------
95
+
96
+ def build_action_group(params, scope, column)
97
+ glue = false
98
+
99
+ params.each do |key, value|
100
+ @sql << scope if glue
101
+ glue = true
102
+
103
+ build_action_types(value, column, key.to_s)
104
+ end
105
+ end
106
+
107
+ def build_action_types(params, column, action)
108
+ if action == "and"
109
+ build_column_types(params, " AND ", column)
110
+ return
111
+ end
112
+
113
+ if action == "or"
114
+ build_column_types(params, " OR ", column)
115
+ return
116
+ end
117
+
118
+ build_action_values(params, column, action)
119
+ end
120
+
121
+ # -------------------------------------------------------------------------
122
+ # Value level — emit the actual SQL comparison
123
+ # -------------------------------------------------------------------------
124
+
125
+ def build_action_values(params, column, action) # rubocop:disable Metrics/MethodLength
126
+ case params
127
+ when TrueClass, FalseClass
128
+ # Only "null" → IS NULL / IS NOT NULL. Boolean equality is not emitted
129
+ # (matches C++ behaviour — use integer 1/0 for boolean equality).
130
+ if action == "null"
131
+ action_str = params ? " IS " : " IS NOT "
132
+ @sql << Sanitizer.keyword_wrap(@table) << "."
133
+ @sql << Sanitizer.keyword_wrap(column)
134
+ @sql << action_str << "NULL"
135
+ end
136
+
137
+ when Integer
138
+ action_name = get_action(action)
139
+ @sql << Sanitizer.keyword_wrap(@table) << "."
140
+ @sql << Sanitizer.keyword_wrap(column)
141
+ @sql << " #{action_name} #{params}"
142
+
143
+ when Float
144
+ action_name = get_action(action)
145
+ @sql << Sanitizer.keyword_wrap(@table) << "."
146
+ @sql << Sanitizer.keyword_wrap(column)
147
+ @sql << " #{action_name} #{params}"
148
+
149
+ when String
150
+ build_action_string(params, column, action)
151
+
152
+ when Array
153
+ action_name = get_action(action)
154
+ @sql << Sanitizer.keyword_wrap(@table) << "."
155
+ @sql << Sanitizer.keyword_wrap(column)
156
+ @sql << " #{action_name} ("
157
+ build_array(params)
158
+ @sql << ")"
159
+
160
+ when Hash
161
+ action_name = get_action(action)
162
+ @sql << Sanitizer.keyword_wrap(@table) << "."
163
+ @sql << Sanitizer.keyword_wrap(column)
164
+ @sql << " #{action_name} ("
165
+ build_object(params)
166
+ @sql << ")"
167
+ end
168
+ end
169
+
170
+ def build_action_string(params, column, action)
171
+ action_name = get_action(action)
172
+
173
+ case action_name
174
+ when "last"
175
+ @sql << Sanitizer.keyword_wrap(@table) << "."
176
+ @sql << Sanitizer.keyword_wrap(column)
177
+ @sql << " LIKE '%" << Sanitizer.value(params) << "'"
178
+
179
+ when "first"
180
+ @sql << Sanitizer.keyword_wrap(@table) << "."
181
+ @sql << Sanitizer.keyword_wrap(column)
182
+ @sql << " LIKE '" << Sanitizer.value(params) << "%'"
183
+
184
+ when "contains"
185
+ @sql << Sanitizer.keyword_wrap(@table) << "."
186
+ @sql << Sanitizer.keyword_wrap(column)
187
+ @sql << " LIKE '%" << Sanitizer.value(params) << "%'"
188
+
189
+ else
190
+ if params.start_with?("$.")
191
+ @sql << Sanitizer.keyword_wrap(@table) << "."
192
+ @sql << Sanitizer.keyword_wrap(column)
193
+ @sql << " #{action_name} "
194
+ @sql << Sanitizer.reference(params)
195
+ else
196
+ @sql << Sanitizer.keyword_wrap(@table) << "."
197
+ @sql << Sanitizer.keyword_wrap(column)
198
+ @sql << " #{action_name} "
199
+ @sql << Sanitizer.value_wrap(params)
200
+ end
201
+ end
202
+ end
203
+
204
+ # -------------------------------------------------------------------------
205
+ # IN-list and subquery helpers
206
+ # -------------------------------------------------------------------------
207
+
208
+ def build_array(array)
209
+ if array.empty?
210
+ @sql << "NULL"
211
+ return
212
+ end
213
+
214
+ glue = false
215
+ array.each do |item|
216
+ @sql << ", " if glue
217
+ glue = true
218
+
219
+ case item
220
+ when Integer, Float then @sql << item.to_s
221
+ when String then @sql << Sanitizer.value_wrap(item)
222
+ end
223
+ end
224
+ end
225
+
226
+ # Builds a UNION of sub-SELECTs (used when action value is a Hash of tables).
227
+ def build_object(object)
228
+ if object.empty?
229
+ @sql << "NULL"
230
+ return
231
+ end
232
+
233
+ glue = false
234
+ relation = WhereRelation.none(@table)
235
+
236
+ object.each do |key, value|
237
+ @sql << " UNION " if glue
238
+ glue = true
239
+
240
+ tbl = key.to_s
241
+ @sql << "("
242
+ SelectModel.new(@sql, tbl, relation).build_query_default(value)
243
+ @sql << ")"
244
+ end
245
+ end
246
+
247
+ # -------------------------------------------------------------------------
248
+ # Operator mapping
249
+ # -------------------------------------------------------------------------
250
+
251
+ def get_action(action)
252
+ case action
253
+ when "=", "<", ">", "<=", ">=", "!=", "<>" then action
254
+ when "in" then "IN"
255
+ when "!in" then "NOT IN"
256
+ when "like" then "LIKE"
257
+ when "!like" then "NOT LIKE"
258
+ else action
259
+ end
260
+ end
261
+ end
262
+ end
@@ -0,0 +1,76 @@
1
+ module Json2sql
2
+ class WhereRelation
3
+ NONE = :none
4
+ CHILD = :child
5
+ PARENT = :parent
6
+
7
+ attr_reader :table, :kind
8
+
9
+ def initialize(table, kind)
10
+ @table = table.to_s
11
+ @kind = kind
12
+ end
13
+
14
+ # Factory: no relationship (top-level query).
15
+ def self.none(table)
16
+ new(table, NONE)
17
+ end
18
+
19
+ # Factory: foreign key is on the child table pointing to the parent.
20
+ # Produces: `parent`.`child_id` = `current`.`id`
21
+ def self.child(table)
22
+ new(table, CHILD)
23
+ end
24
+
25
+ # Factory: foreign key is on the current/parent table pointing to the child.
26
+ # Produces: `current`.`parent_id` = `parent`.`id`
27
+ def self.parent(table)
28
+ new(table, PARENT)
29
+ end
30
+
31
+ # Appends the JOIN condition for this relationship into sql.
32
+ # +current+ is the name of the table being queried.
33
+ def build_table_relation(sql, current)
34
+ current = current.to_s
35
+
36
+ if kind == CHILD
37
+ sql << Sanitizer.keyword_wrap(table)
38
+ sql << "."
39
+ sql << build_table_id(current)
40
+ sql << " = "
41
+ sql << Sanitizer.keyword_wrap(current)
42
+ sql << ".`id`"
43
+ return
44
+ end
45
+
46
+ if kind == PARENT
47
+ sql << Sanitizer.keyword_wrap(current)
48
+ sql << "."
49
+ sql << build_table_id(table)
50
+ sql << " = "
51
+ sql << Sanitizer.keyword_wrap(table)
52
+ sql << ".`id`"
53
+ end
54
+ end
55
+
56
+ # Converts a (possibly plural) table name to its foreign-key column
57
+ # name wrapped in backticks.
58
+ # "users" → "`user_id`"
59
+ # "categories" → "`category_id`"
60
+ # "admins" → "`admin_id`"
61
+ def build_table_id(tbl)
62
+ tbl = tbl.to_s
63
+ base = Sanitizer.keyword(tbl)
64
+
65
+ name = if base.end_with?("ies")
66
+ base[0..-4] + "y"
67
+ elsif base.end_with?("s")
68
+ base[0..-2]
69
+ else
70
+ base
71
+ end
72
+
73
+ "`#{name}_id`"
74
+ end
75
+ end
76
+ end
data/lib/json2sql.rb ADDED
@@ -0,0 +1,38 @@
1
+ require_relative "json2sql/version"
2
+ require_relative "json2sql/sanitizer"
3
+ require_relative "json2sql/where_relation"
4
+ require_relative "json2sql/where_model"
5
+ require_relative "json2sql/select_model"
6
+ require_relative "json2sql/select_runner"
7
+ require_relative "json2sql/insert_model"
8
+ require_relative "json2sql/insert_runner"
9
+ require_relative "json2sql/update_model"
10
+ require_relative "json2sql/update_runner"
11
+ require_relative "json2sql/delete_model"
12
+ require_relative "json2sql/delete_runner"
13
+
14
+ # Json2sql — SQL builder that generates MySQL/MariaDB query strings from
15
+ # plain Ruby Hashes (or parsed JSON).
16
+ #
17
+ # All Hash keys may be either Strings or Symbols; they are normalized to
18
+ # Strings internally before processing.
19
+ #
20
+ # Entry points:
21
+ # Json2sql::SelectRunner.build(hash) → String
22
+ # Json2sql::InsertRunner.build(hash) → String
23
+ # Json2sql::UpdateRunner.build(hash) → String
24
+ # Json2sql::DeleteRunner.build(hash) → String
25
+ module Json2sql
26
+ # Deep-converts all Hash keys to Strings and recurses into nested Hashes
27
+ # and Arrays. Leaves all other values (Integers, Strings, etc.) unchanged.
28
+ def self.normalize(obj)
29
+ case obj
30
+ when Hash
31
+ obj.each_with_object({}) { |(k, v), h| h[k.to_s] = normalize(v) }
32
+ when Array
33
+ obj.map { |v| normalize(v) }
34
+ else
35
+ obj
36
+ end
37
+ end
38
+ end
metadata ADDED
@@ -0,0 +1,88 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: json2sql
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Tiago da Silva
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-04-13 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: minitest
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '5.0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '5.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '13.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '13.0'
41
+ description: Pure-Ruby SQL builder. No runtime dependencies. Supports SELECT (with
42
+ JSON aggregation and nesting), INSERT, UPDATE, and DELETE for MySQL/MariaDB.
43
+ email:
44
+ - tyagoy@gmail.com
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - LICENSE.txt
50
+ - lib/json2sql.rb
51
+ - lib/json2sql/delete_model.rb
52
+ - lib/json2sql/delete_runner.rb
53
+ - lib/json2sql/insert_model.rb
54
+ - lib/json2sql/insert_runner.rb
55
+ - lib/json2sql/sanitizer.rb
56
+ - lib/json2sql/select_model.rb
57
+ - lib/json2sql/select_runner.rb
58
+ - lib/json2sql/update_model.rb
59
+ - lib/json2sql/update_runner.rb
60
+ - lib/json2sql/version.rb
61
+ - lib/json2sql/where_model.rb
62
+ - lib/json2sql/where_relation.rb
63
+ homepage: https://github.com/tyagoy/json2sql
64
+ licenses:
65
+ - MIT
66
+ metadata:
67
+ homepage_uri: https://github.com/tyagoy/json2sql
68
+ source_code_uri: https://github.com/tyagoy/json2sql
69
+ post_install_message:
70
+ rdoc_options: []
71
+ require_paths:
72
+ - lib
73
+ required_ruby_version: !ruby/object:Gem::Requirement
74
+ requirements:
75
+ - - ">="
76
+ - !ruby/object:Gem::Version
77
+ version: '2.7'
78
+ required_rubygems_version: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ requirements: []
84
+ rubygems_version: 3.4.10
85
+ signing_key:
86
+ specification_version: 4
87
+ summary: Translates Ruby Hashes (or parsed JSON) into MySQL/MariaDB query strings.
88
+ test_files: []