Project

json2sql

0.0
The project is in a healthy, maintained state
Pure-Ruby SQL builder. No runtime dependencies. Supports SELECT (with JSON aggregation and nesting), INSERT, UPDATE, and DELETE for MySQL/MariaDB.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

~> 5.0
~> 13.0
 Project Readme

json2sql

Pure-Ruby SQL builder. Translates Ruby Hashes (or parsed JSON) into MySQL/MariaDB query strings.

  • No runtime dependencies
  • String and Symbol keys are both accepted
  • Target: MySQL 8.0+

Installation

gem install json2sql

Or in your Gemfile:

gem "json2sql"

Usage

require "json2sql"

All entry points are stateless class methods that return a SQL string.


SELECT

Json2sql::SelectRunner.build(hash)  String

The result is always a SELECT JSON_OBJECT(…) query. Multiple top-level keys produce multiple named subqueries wrapped in a single outer JSON_OBJECT.

Basic

Json2sql::SelectRunner.build(
  "users" => { "columns" => ["id", "name", "email"] }
)

WHERE conditions

Conditions live under the "and" or "or" key.

Json2sql::SelectRunner.build(
  "users" => {
    "columns" => ["id", "name"],
    "and" => {
      "active" => 1,       # Integer → col = 1
      "name"   => "john",  # String  → col LIKE '%john%'
    }
  }
)

Explicit operators

Key SQL
{ "=" => value } col = value
{ "!=" => value } or { "<>" => value } col != value
{ ">" => value } col > value
{ ">=" => value } col >= value
{ "<" => value } col < value
{ "<=" => value } col <= value
{ "in" => [1, 2, 3] } col IN (1, 2, 3)
{ "!in" => [1, 2] } col NOT IN (1, 2)
{ "like" => "%.com" } col LIKE '%.com'
{ "!like" => "%.com" } col NOT LIKE '%.com'
{ "contains" => "john" } col LIKE '%john%'
{ "first" => "Jo" } col LIKE 'Jo%'
{ "last" => "son" } col LIKE '%son'
{ "null" => true } col IS NULL
{ "null" => false } col IS NOT NULL
Json2sql::SelectRunner.build(
  "users" => {
    "columns" => ["id", "name"],
    "and" => {
      "age"        => { ">=" => 18 },
      "role"       => { "!in" => [0, 9] },
      "deleted_at" => { "null" => true },
      "email"      => { "last" => ".com" }
    }
  }
)

Column cross-references

Use "$.table.column" syntax to reference another column instead of a literal value:

"and" => { "author_id" => { "=" => "$.users.id" } }
# → `posts`.`author_id` = `users`.`id`

Nested AND / OR

"and" => {
  "active" => 1,
  "or" => { "role" => 1, "admin" => 1 }
}

ORDER BY

"order" => { "created_at" => "desc", "name" => "asc" }

LIMIT and OFFSET

"limit" => 20, "offset" => 40

Total count (options)

Adding "options" => ["total"] wraps the result in { "data": […], "total": N } by running an additional COUNT(*) subquery.

Json2sql::SelectRunner.build(
  "users" => {
    "columns" => ["id", "name"],
    "and"     => { "active" => 1 },
    "order"   => { "created_at" => "desc" },
    "limit"   => 20,
    "offset"  => 0,
    "options" => ["total"]
  }
)

Function columns and bigint-safe JSON

Function columns in SELECT use objects in the columns array with alias, function and params.

You can use generic SQL CAST in function columns via:

  • {"alias" => "field", "function" => "CAST", "params" => ["source_column", "TYPE"]}

To return 64-bit integer columns as JSON strings (avoiding precision loss in JS clients), use TYPE = "CHAR":

Json2sql::SelectRunner.build(
  "devices" => {
    "columns" => [
      { "alias" => "id", "function" => "CAST", "params" => ["id", "CHAR"] },
      "name"
    ]
  }
)

This generates a JSON value like CAST(devices.id AS CHAR) for the aliased key.

Nested children (one-to-many)

Json2sql::SelectRunner.build(
  "users" => {
    "columns"  => ["id", "name"],
    "children" => {
      "posts" => { "columns" => ["id", "title"] }
    }
  }
)
# JOIN condition: `posts`.`user_id` = `users`.`id`

Nested parents (many-to-one)

Json2sql::SelectRunner.build(
  "posts" => {
    "columns" => ["id", "title"],
    "parents" => {
      "users" => { "columns" => ["id", "name"] }
    }
  }
)
# JOIN condition: `posts`.`user_id` = `users`.`id`

Nesting is recursive — children can have their own children.

Multiple tables

Json2sql::SelectRunner.build(
  "users"    => { "columns" => ["id", "name"] },
  "products" => { "columns" => ["id", "price"] }
)
# → SELECT JSON_OBJECT('users', (…), 'products', (…));

INSERT

Json2sql::InsertRunner.build(hash)  String

"columns" is a Hash of column => value.

Single row

Json2sql::InsertRunner.build(
  "users" => { "columns" => { "name" => "João", "email" => "joao@example.com" } }
)
# → INSERT INTO `users` (`name`, `email`) VALUES ('João', 'joao@example.com');

Multiple rows

Pass an Array of row hashes:

Json2sql::InsertRunner.build(
  "tags" => [
    { "columns" => { "name" => "ruby" } },
    { "columns" => { "name" => "rails" } }
  ]
)
# → INSERT INTO `tags` (`name`) VALUES ('ruby');
#   INSERT INTO `tags` (`name`) VALUES ('rails');

UPDATE

Json2sql::UpdateRunner.build(hash)  String
Json2sql::UpdateRunner.build(
  "users" => {
    "columns" => { "name" => "Maria", "active" => 1 },
    "and"     => { "id" => 42 }
  }
)
# → UPDATE `users` SET `users`.`name` = 'Maria', `users`.`active` = 1 WHERE (`users`.`id` = 42);

DELETE

Json2sql::DeleteRunner.build(hash)  String
Json2sql::DeleteRunner.build(
  "users" => { "and" => { "id" => 42 } }
)
# → DELETE FROM `users` WHERE (`users`.`id` = 42);

Value types

Ruby type SQL output
Integer raw number
Float raw number
String 'escaped value'

Single quotes in strings are doubled (O'Brien'O''Brien'). Backslashes are escaped.

Security

Table and column names are sanitized by stripping characters outside [a-zA-Z0-9_-]. Malformed identifiers become mangled but harmless (e.g. "users; DROP TABLE"`usersDROPTABLE`). Values are always wrapped in quoted literals.


QueryPolicy

Json2sql::QueryPolicy sanitizes a raw input Hash before passing it to any Runner. Use it as a server-side access control layer — it enforces which tables, columns, children and parents a caller may query, and injects forced WHERE conditions to prevent IDOR.

policy = Json2sql::QueryPolicy.new(
  mode:   :allow,
  tables: {
    orders: {
      columns:  %w[id total status],
      children: { order_items: { columns: %w[id price] } },
      parents:  { users:       { columns: %w[id name] } },
      where:    { "and" => { "user_id" => 42 } }
    }
  }
)

safe_input = policy.apply(raw_params)
sql        = Json2sql::SelectRunner.build(safe_input)

mode

Value Behaviour
:allow (default) Only tables listed in tables: are accessible. Tables absent from the config are blocked entirely. Empty tables: blocks everything.
:deny All tables pass. After column filtering, tables with no remaining columns are removed. Same rule applies to children and parents.

Per-table configuration

{
  table_name => {
    columns:  [...],          # column list
    children: { child  => { columns: [...], ... } },
    parents:  { parent => { columns: [...], ... } },
    where:    { "and"  => { col => val } }
  }
}
Key :allow mode :deny mode
columns absent or nil all columns blocked columns untouched
columns: [...] only listed columns pass; function-column Hashes always pass listed columns are removed
children / parents absent all relations blocked relations untouched
children: { t => {...} } only listed child tables pass listed child tables are removed
where: { "and" => {...} } forced conditions merged into "and"; forced keys overwrite user-supplied values same

IDOR guard

Forced where keys always overwrite the user-supplied value for the same column:

# config: where: { "and" => { "user_id" => 42 } }
# user sends: "and" => { "user_id" => 999 }
# result:     "and" => { "user_id" => 42 }   ← attacker cannot override

Recursive nesting

children and parents configs are recursive — each nested table accepts its own columns, children, parents and where.

Json2sql::QueryPolicy.new(
  tables: {
    orders: {
      columns: %w[id total],
      children: {
        order_items: {
          columns: %w[id price],
          parents: { products: { columns: %w[id name] } }
        }
      }
    }
  }
)

String and Symbol keys

Both are accepted — normalized internally.

Json2sql::QueryPolicy.new(tables: { orders: { columns: %w[id] } })
# same as
Json2sql::QueryPolicy.new(tables: { "orders" => { "columns" => %w[id] } })

Pitfalls

  • No boolean equality — use 1/0. true/false only works with the "null" operator.
  • "options" => ["total"] doubles query cost — runs two subqueries. Ensure proper indexes.
  • Empty in array emits IN (NULL) — always false, intentional.
  • LATERAL subqueries — requires MySQL 8.0+.

Development

bundle exec rake test     # run test suite
bundle exec rake build    # build .gem to pkg/
bundle exec rake release  # tag + push + publish to RubyGems.org

License

MIT