jsonq 0.2.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: 676bb06e71303384551e79e47c6dae85d27b14b3213a23bb2aea09b8cabdfaa7
4
+ data.tar.gz: a41e7d14172dfd1ccb3f2127478705bdedc64cd3e08fa3ec1306d6db06076290
5
+ SHA512:
6
+ metadata.gz: 164edf008bcfc3c4259d33c1938f0503c9f2b95728bc74b1131dfe5b823fe5e814b646b6343eea3ba5bdee6e8cd536b2c90c7bf39aa9569f7ace952463db86df
7
+ data.tar.gz: 1724bf837a1946aed6e4526ac477c4b9b164c0e48400a70886f02c922ea0598ae5f728aa7fba11856da131d7d65550f03976d86821394b397a3f98597c5b7797
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Adrian Marin
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.
data/README.md ADDED
@@ -0,0 +1,139 @@
1
+ # jsonq
2
+
3
+ [![Testing](https://github.com/avo-hq/jsonq/actions/workflows/testing.yml/badge.svg)](https://github.com/avo-hq/jsonq/actions/workflows/testing.yml)
4
+
5
+ Friendly JSON column queries for ActiveRecord. Use `where` with JSON attributes just like regular columns.
6
+
7
+ Supports PostgreSQL, MySQL, and SQLite.
8
+
9
+ ## Installation
10
+
11
+ Add to your Gemfile:
12
+
13
+ ```ruby
14
+ gem "jsonq"
15
+ ```
16
+
17
+ ## Getting Started
18
+
19
+ Add `jsonq_queryable` to your model:
20
+
21
+ ```ruby
22
+ class Plan < ApplicationRecord
23
+ jsonq_queryable
24
+
25
+ store_accessor :metadata, :private_title, :category, :author
26
+ end
27
+ ```
28
+
29
+ Order doesn't matter — `jsonq_queryable` can go before or after `store_accessor`.
30
+
31
+ Now query JSON attributes with `where`:
32
+
33
+ ```ruby
34
+ Plan.where(private_title: "Draft")
35
+ Plan.where(category: "work", status: "active")
36
+ Plan.where(private_title: ["Draft", "Review"])
37
+ Plan.where(private_title: nil)
38
+ Plan.where.not(category: "archived")
39
+ ```
40
+
41
+ ## How It Works
42
+
43
+ jsonq intercepts ActiveRecord's `PredicateBuilder` to translate JSON attribute names into database-specific path expressions:
44
+
45
+ ```
46
+ Plan.where(private_title: "Draft")
47
+ ```
48
+
49
+ Generates:
50
+
51
+ ```sql
52
+ -- PostgreSQL
53
+ WHERE "plans"."metadata"->>'private_title' = 'Draft'
54
+
55
+ -- MySQL
56
+ WHERE JSON_UNQUOTE(JSON_EXTRACT("plans"."metadata", '$.private_title')) = 'Draft'
57
+
58
+ -- SQLite
59
+ WHERE "plans"."metadata"->>'$.private_title' = 'Draft'
60
+ ```
61
+
62
+ ## store_accessor Integration
63
+
64
+ Any `store_accessor` attributes on a native JSON/JSONB column are automatically registered. Declarations before or after `jsonq_queryable` both work.
65
+
66
+ ```ruby
67
+ class Plan < ApplicationRecord
68
+ jsonq_queryable
69
+
70
+ store_accessor :metadata, :private_title, :category
71
+ end
72
+
73
+ Plan.where(private_title: "Draft")
74
+ ```
75
+
76
+ When a `store_accessor` attribute name collides with a real database column, the real column takes precedence.
77
+
78
+ Attributes declared with `prefix:` or `suffix:` are registered by their original key name (the JSON key), not the prefixed accessor name. Use `json_attribute` to query by a custom name.
79
+
80
+ ## Standalone DSL
81
+
82
+ For nested JSON paths or attributes without `store_accessor`, use `json_attribute`:
83
+
84
+ ```ruby
85
+ class Event < ApplicationRecord
86
+ jsonq_queryable
87
+
88
+ json_attribute :metadata, "address.billing.city", as: :billing_city
89
+ json_attribute :metadata, "organizer.name", as: :organizer_name
90
+ end
91
+
92
+ Event.where(billing_city: "Paris")
93
+ Event.where(organizer_name: "Alice")
94
+ ```
95
+
96
+ The `as:` option is required.
97
+
98
+ ## Query Support
99
+
100
+ | Operation | Example |
101
+ | ----------- | ---------------------------------------- |
102
+ | Equality | `where(key: "value")` |
103
+ | Nil/NULL | `where(key: nil)` |
104
+ | IN (array) | `where(key: ["a", "b"])` |
105
+ | Negation | `where.not(key: "value")` |
106
+ | Composition | `where(json_key: "x", real_column: "y")` |
107
+ | Chaining | `where(key: "x").where(other_key: "y")` |
108
+
109
+ ## Database Support
110
+
111
+ - **PostgreSQL** — JSONB and JSON columns
112
+ - **MySQL 5.7+** — JSON columns
113
+ - **SQLite 3.38+** — JSON1 extension
114
+
115
+ The adapter is detected automatically from the ActiveRecord connection.
116
+
117
+ ## Non-Rails Usage
118
+
119
+ If you use ActiveRecord without Rails, call `Jsonq.setup!` after establishing a connection:
120
+
121
+ ```ruby
122
+ require "jsonq"
123
+
124
+ ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
125
+ Jsonq.setup!
126
+ ```
127
+
128
+ ## Requirements
129
+
130
+ - Ruby >= 3.0
131
+ - ActiveRecord >= 7.0
132
+
133
+ ## License
134
+
135
+ MIT
136
+
137
+ ## Contributing
138
+
139
+ Bug reports and pull requests are welcome on [GitHub](https://github.com/avo-hq/jsonq).
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jsonq
4
+ module Adapters
5
+ class AbstractAdapter
6
+ def build_path_expression(arel_table, column, path)
7
+ raise NotImplementedError, "#{self.class}#build_path_expression must be implemented"
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jsonq
4
+ module Adapters
5
+ class MysqlAdapter < AbstractAdapter
6
+ def build_path_expression(arel_table, column, path)
7
+ json_path = "$.#{path.join(".")}"
8
+
9
+ extract = Arel::Nodes::NamedFunction.new(
10
+ "JSON_EXTRACT",
11
+ [arel_table[column], Arel::Nodes.build_quoted(json_path)]
12
+ )
13
+
14
+ Arel::Nodes::NamedFunction.new("JSON_UNQUOTE", [extract])
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jsonq
4
+ module Adapters
5
+ class PostgresqlAdapter < AbstractAdapter
6
+ def build_path_expression(arel_table, column, path)
7
+ node = arel_table[column]
8
+
9
+ path.each_with_index do |key, index|
10
+ operator = (index == path.length - 1) ? "->>" : "->"
11
+ node = Arel::Nodes::InfixOperation.new(operator, node, Arel::Nodes.build_quoted(key))
12
+ end
13
+
14
+ node
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jsonq
4
+ module Adapters
5
+ class SqliteAdapter < AbstractAdapter
6
+ def build_path_expression(arel_table, column, path)
7
+ json_path = "$.#{path.join(".")}"
8
+
9
+ Arel::Nodes::InfixOperation.new(
10
+ "->>",
11
+ arel_table[column],
12
+ Arel::Nodes.build_quoted(json_path)
13
+ )
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "adapters/abstract_adapter"
4
+ require_relative "adapters/postgresql_adapter"
5
+ require_relative "adapters/mysql_adapter"
6
+ require_relative "adapters/sqlite_adapter"
7
+
8
+ module Jsonq
9
+ module Adapters
10
+ def self.for_connection(connection)
11
+ case connection.adapter_name
12
+ when /postg/i
13
+ PostgresqlAdapter.new
14
+ when /mysql|trilogy/i
15
+ MysqlAdapter.new
16
+ when /sqlite/i
17
+ SqliteAdapter.new
18
+ else
19
+ raise Jsonq::UnsupportedAdapter,
20
+ "jsonq: unsupported database adapter '#{connection.adapter_name}'. " \
21
+ "Supported adapters: PostgreSQL, MySQL, SQLite."
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jsonq
4
+ module PredicateBuilderExtension
5
+ protected
6
+
7
+ def expand_from_hash(attributes, &block)
8
+ klass = begin
9
+ @table.send(:klass)
10
+ rescue
11
+ nil
12
+ end
13
+
14
+ return super unless klass&.respond_to?(:jsonq_registry) && klass.jsonq_registry.present?
15
+
16
+ registry = klass.jsonq_registry
17
+ jsonq_predicates = []
18
+ regular_attributes = {}
19
+
20
+ attributes.each do |key, value|
21
+ key_str = key.to_s
22
+
23
+ if registry.key?(key_str)
24
+ mapping = registry[key_str]
25
+ adapter = Jsonq::Adapters.for_connection(klass.connection)
26
+ arel_table = klass.arel_table
27
+ path_expr = adapter.build_path_expression(arel_table, mapping[:column], mapping[:path])
28
+
29
+ predicate = case value
30
+ when Array
31
+ # Wrap with AND IS NOT NULL so NOT(IN(...) AND IS NOT NULL)
32
+ # becomes NOT IN(...) OR IS NULL — includes missing keys
33
+ Arel::Nodes::Grouping.new(
34
+ path_expr.in(value.map(&:to_s)).and(path_expr.not_eq(nil))
35
+ )
36
+ when nil
37
+ path_expr.eq(nil)
38
+ else
39
+ # Wrap with AND IS NOT NULL so NOT(= val AND IS NOT NULL)
40
+ # becomes != val OR IS NULL — includes missing keys
41
+ Arel::Nodes::Grouping.new(
42
+ path_expr.eq(value.to_s).and(path_expr.not_eq(nil))
43
+ )
44
+ end
45
+
46
+ jsonq_predicates << predicate
47
+ else
48
+ regular_attributes[key] = value
49
+ end
50
+ end
51
+
52
+ result = regular_attributes.empty? ? [] : super(regular_attributes, &block)
53
+ result + jsonq_predicates
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module Jsonq
6
+ module Queryable
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ class_attribute :jsonq_registry, instance_writer: false, default: {}
11
+
12
+ _jsonq_register_store_accessors
13
+ end
14
+
15
+ class_methods do
16
+ def store_accessor(store_attribute, *keys, prefix: nil, suffix: nil)
17
+ super
18
+ _jsonq_register_keys_for_store(store_attribute, keys) if respond_to?(:jsonq_registry)
19
+ end
20
+
21
+ def json_attribute(column, path, as: nil)
22
+ raise ArgumentError, "jsonq: `as:` option is required for json_attribute" if as.nil?
23
+
24
+ column = column.to_s
25
+ _jsonq_validate_json_column!(column)
26
+
27
+ path_parts = path.to_s.split(".")
28
+ alias_name = as.to_s
29
+
30
+ self.jsonq_registry = jsonq_registry.merge(
31
+ alias_name => {column: column, path: path_parts, source: :json_attribute}
32
+ )
33
+ end
34
+
35
+ private
36
+
37
+ def _jsonq_register_keys_for_store(store_attribute, keys)
38
+ store_column_str = store_attribute.to_s
39
+
40
+ begin
41
+ _jsonq_validate_json_column!(store_column_str)
42
+ rescue Jsonq::UnsupportedColumnType
43
+ raise
44
+ rescue
45
+ return
46
+ end
47
+
48
+ new_entries = {}
49
+ keys.each do |key|
50
+ key_str = key.to_s
51
+ next if columns_hash.key?(key_str)
52
+
53
+ new_entries[key_str] = {column: store_column_str, path: [key_str], source: :store_accessor}
54
+ end
55
+
56
+ self.jsonq_registry = jsonq_registry.merge(new_entries) if new_entries.any?
57
+ end
58
+
59
+ def _jsonq_register_store_accessors
60
+ return unless respond_to?(:stored_attributes) && stored_attributes.present?
61
+
62
+ stored_attributes.each do |store_column, keys|
63
+ _jsonq_register_keys_for_store(store_column, keys)
64
+ end
65
+ end
66
+
67
+ def _jsonq_validate_json_column!(column_name)
68
+ return unless connected? && table_exists?
69
+
70
+ col = columns_hash[column_name]
71
+ return if col.nil?
72
+
73
+ sql_type = col.sql_type.downcase
74
+ unless sql_type.include?("json")
75
+ raise Jsonq::UnsupportedColumnType,
76
+ "jsonq: column '#{column_name}' has type '#{col.sql_type}'. " \
77
+ "Only native JSON/JSONB columns are supported."
78
+ end
79
+ end
80
+ end
81
+ end
82
+
83
+ module QueryableDsl
84
+ def jsonq_queryable
85
+ include Jsonq::Queryable
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module Jsonq
6
+ class Railtie < ::Rails::Railtie
7
+ initializer "jsonq.active_record" do
8
+ ActiveSupport.on_load :active_record do
9
+ ActiveRecord::PredicateBuilder.prepend(Jsonq::PredicateBuilderExtension)
10
+ ActiveRecord::Base.extend(Jsonq::QueryableDsl)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jsonq
4
+ VERSION = "0.2.1"
5
+ end
data/lib/jsonq.rb ADDED
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "jsonq/version"
4
+ require_relative "jsonq/adapters"
5
+ require_relative "jsonq/queryable"
6
+ require_relative "jsonq/predicate_builder_extension"
7
+ require_relative "jsonq/railtie" if defined?(Rails)
8
+
9
+ module Jsonq
10
+ class Error < StandardError; end
11
+ class UnsupportedColumnType < Error; end
12
+ class UnsupportedAdapter < Error; end
13
+
14
+ def self.setup!
15
+ ActiveRecord::PredicateBuilder.prepend(Jsonq::PredicateBuilderExtension)
16
+ ActiveRecord::Base.extend(Jsonq::QueryableDsl)
17
+ end
18
+ end
metadata ADDED
@@ -0,0 +1,70 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: jsonq
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.1
5
+ platform: ruby
6
+ authors:
7
+ - Adrian Marin
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activerecord
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.0'
26
+ description: Query JSON/JSONB columns using ActiveRecord's where syntax. Works with
27
+ store_accessor and supports PostgreSQL, MySQL, and SQLite.
28
+ email: adrian@adrianthedev.com
29
+ executables: []
30
+ extensions: []
31
+ extra_rdoc_files: []
32
+ files:
33
+ - LICENSE
34
+ - README.md
35
+ - lib/jsonq.rb
36
+ - lib/jsonq/adapters.rb
37
+ - lib/jsonq/adapters/abstract_adapter.rb
38
+ - lib/jsonq/adapters/mysql_adapter.rb
39
+ - lib/jsonq/adapters/postgresql_adapter.rb
40
+ - lib/jsonq/adapters/sqlite_adapter.rb
41
+ - lib/jsonq/predicate_builder_extension.rb
42
+ - lib/jsonq/queryable.rb
43
+ - lib/jsonq/railtie.rb
44
+ - lib/jsonq/version.rb
45
+ homepage: https://github.com/avo-hq/jsonq
46
+ licenses:
47
+ - MIT
48
+ metadata:
49
+ homepage_uri: https://github.com/avo-hq/jsonq
50
+ source_code_uri: https://github.com/avo-hq/jsonq
51
+ bug_tracker_uri: https://github.com/avo-hq/jsonq/issues
52
+ changelog_uri: https://github.com/avo-hq/jsonq/releases
53
+ rdoc_options: []
54
+ require_paths:
55
+ - lib
56
+ required_ruby_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '3.0'
61
+ required_rubygems_version: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: '0'
66
+ requirements: []
67
+ rubygems_version: 3.6.9
68
+ specification_version: 4
69
+ summary: Friendly JSON column queries for ActiveRecord.
70
+ test_files: []