activerecord-sqlite-types 0.3.3 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 81175508913f3b2ce7a13f10d48dd86bfa7c040bd77945af0c1b14c95468847d
4
- data.tar.gz: 01fe17d2fbc47bde9745418246959cfe376e1a454bd6448b723ec2a2f09b5974
3
+ metadata.gz: 3b2ce466647cec30a5ddb1f87e7f2721fad43f8a8de5eeeba8bf19172ef8386a
4
+ data.tar.gz: fb6289b9981c9e0e79ef4db08aa8436906310b4c0a8849dba8406176a31064f5
5
5
  SHA512:
6
- metadata.gz: be09ffdd30ac8794d139836d669a2f410caad99abf4e247b95684876c767a413e769840953cd8d13cdfd09a318f5b46e055830a87b9b46780a1d636a22a8eebc
7
- data.tar.gz: 64b75feb538d840a567201e486e4685a9fbf0483e9fa9cc514a2f75bad8a2677a15386dadf8163d2fe999f3779506059d64c2a39ad76ba99b5c0aa9059262f3f
6
+ metadata.gz: 417f05eb69a1df1a10effc7af883c3beb2130248fd94c23f7bc628323d195e09bb8072e9ad27d7eb34e7a737ce0c9e5b809af649c5c356e58ef753388a871186
7
+ data.tar.gz: 02b49246f0ecb7ee52c17de2392710f6e99a6ed3e3f4cc7966c396ea44b29bb9487795f027291d5d19b89d7aba7d4aff90f76fc4da2d3b5e3ea361500634bca8
data/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.4.0] - 2026-05-15
4
+
5
+ ### Added
6
+
7
+ - Add SQLite JSON-backed array column query scopes for containment, overlap, and negated matches
8
+
3
9
  ## [0.3.3] - 2026-05-14
4
10
 
5
11
  ### Fixed
data/README.md CHANGED
@@ -115,7 +115,35 @@ end
115
115
 
116
116
  **Querying arrays:**
117
117
 
118
- For array querying functionality, see [Stephen Margheim's article on enhancing Rails SQLite array columns](https://fractaledmind.github.io/2023/09/12/enhancing-rails-sqlite-array-columns/).
118
+ Include `SQLiteTypes::ArrayColumns` in models where you want tag-style querying helpers for JSON-backed array columns:
119
+
120
+ ```ruby
121
+ class Event < ApplicationRecord
122
+ include SQLiteTypes::ArrayColumns
123
+
124
+ attribute :relationship_statuses, SQLiteTypes::Array.new(:string)
125
+ array_columns :relationship_statuses
126
+ end
127
+ ```
128
+
129
+ Declaring an array column adds sanitization before validation, class aggregate methods, scopes, and instance predicates:
130
+
131
+ ```ruby
132
+ Event.unique_relationship_statuses
133
+ Event.relationship_statuses_cloud
134
+ Event.with_relationship_statuses
135
+ Event.without_relationship_statuses
136
+ Event.with_any_relationship_statuses("single", "partnered")
137
+ Event.with_all_relationship_statuses("single", "partnered")
138
+ Event.without_any_relationship_statuses("single")
139
+ Event.without_all_relationship_statuses("single", "partnered")
140
+
141
+ event.has_any_relationship_statuses?("single", "partnered")
142
+ event.has_all_relationship_statuses?("single", "partnered")
143
+ event.has_relationship_status?("single")
144
+ ```
145
+
146
+ Values are compacted, stringified, deduplicated, and sorted before validation and query comparison. The scopes use SQLite's `JSON_EACH` function and are based on [Stephen Margheim's article on enhancing Rails SQLite array columns](https://fractaledmind.com/2023/09/12/enhancing-rails-sqlite-array-columns/).
119
147
 
120
148
  ### Interval
121
149
 
@@ -1,10 +1,13 @@
1
1
  require "active_record"
2
+ require "active_support/concern"
3
+ require "active_support/core_ext/object/blank"
2
4
  require "active_support/time"
3
5
  require "date"
4
6
  require "ipaddr"
5
7
  require_relative "sqlite_types/version"
6
8
  require_relative "sqlite_types/ip_address"
7
9
  require_relative "sqlite_types/array"
10
+ require_relative "sqlite_types/array_columns"
8
11
  require_relative "sqlite_types/interval"
9
12
  require_relative "sqlite_types/migration_helpers"
10
13
 
@@ -92,10 +92,11 @@ module SQLiteTypes
92
92
  elem
93
93
  when :hash
94
94
  return elem if json_compatible?(elem)
95
- return json_round_trip(elem) if elem.is_a?(::Hash)
96
95
 
97
96
  serialized = json_round_trip(elem)
98
- raise ArgumentError, "Invalid #{@subtype} array element: #{elem.inspect}" unless serialized.is_a?(::Hash) && serialized.any?
97
+ unless serialized.is_a?(::Hash) && serialized.any?
98
+ raise ArgumentError, "Invalid #{@subtype} array element: #{elem.inspect}"
99
+ end
99
100
 
100
101
  serialized
101
102
  when :json, :jsonb
@@ -204,7 +205,7 @@ module SQLiteTypes
204
205
  end
205
206
 
206
207
  def json_round_trip(value)
207
- ::ActiveSupport::JSON.decode(::ActiveSupport::JSON.encode(value))
208
+ ActiveSupport::JSON.decode(ActiveSupport::JSON.encode(value))
208
209
  rescue JSON::ParserError, TypeError, EncodingError
209
210
  raise ArgumentError, "Invalid #{@subtype} array element: #{value.inspect}"
210
211
  end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SQLiteTypes
4
+ module ArrayColumns
5
+ extend ActiveSupport::Concern
6
+
7
+ class_methods do
8
+ def array_columns_sanitize_list(values = [])
9
+ return [] if values.nil?
10
+
11
+ values.select(&:present?).map(&:to_s).uniq.sort
12
+ end
13
+
14
+ def array_columns(*column_names)
15
+ @array_columns ||= {}
16
+
17
+ array_columns_sanitize_list(column_names).each do |column_name|
18
+ @array_columns[column_name] ||= false
19
+ end
20
+
21
+ @array_columns.each do |column_name, initialized|
22
+ next if initialized
23
+
24
+ define_array_column_methods(column_name)
25
+ @array_columns[column_name] = true
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def define_array_column_methods(column_name)
32
+ method_name = column_name.downcase
33
+ json_each = Arel::Nodes::NamedFunction.new("JSON_EACH", [arel_table[column_name]])
34
+
35
+ define_array_column_aggregate_methods(method_name, json_each)
36
+ define_array_column_presence_scopes(method_name, column_name)
37
+ define_array_column_query_scopes(method_name, json_each)
38
+ define_array_column_predicates(method_name, column_name)
39
+ define_array_column_writer(column_name)
40
+
41
+ before_validation -> { self[column_name] = self.class.array_columns_sanitize_list(self[column_name]) }
42
+ end
43
+
44
+ def define_array_column_aggregate_methods(method_name, json_each)
45
+ define_singleton_method :"unique_#{method_name}" do |_conditions = "true"|
46
+ select("value")
47
+ .from([arel_table, json_each])
48
+ .distinct
49
+ .pluck("value")
50
+ .sort
51
+ end
52
+
53
+ define_singleton_method :"#{method_name}_cloud" do |_conditions = "true"|
54
+ select("value")
55
+ .from([arel_table, json_each])
56
+ .group("value")
57
+ .order("value")
58
+ .pluck(Arel.sql("value, COUNT(*) AS count"))
59
+ .to_h
60
+ end
61
+ end
62
+
63
+ def define_array_column_presence_scopes(method_name, column_name)
64
+ scope :"with_#{method_name}", -> {
65
+ where.not(arel_table[column_name].eq(nil))
66
+ .where.not(arel_table[column_name].eq([]))
67
+ }
68
+
69
+ scope :"without_#{method_name}", -> {
70
+ where(arel_table[column_name].eq(nil))
71
+ .or(where(arel_table[column_name].eq([])))
72
+ }
73
+ end
74
+
75
+ def define_array_column_query_scopes(method_name, json_each)
76
+ overlap_query = ->(items) {
77
+ values = array_columns_sanitize_list(items)
78
+
79
+ Arel::SelectManager.new(json_each)
80
+ .project(1)
81
+ .where(Arel.sql("CAST(value AS TEXT)").in(values))
82
+ .take(1)
83
+ .exists
84
+ }
85
+
86
+ contains_query = ->(items) {
87
+ values = array_columns_sanitize_list(items)
88
+ count = Arel::SelectManager.new(json_each)
89
+ .project(Arel.sql("value").count(true))
90
+ .where(Arel.sql("CAST(value AS TEXT)").in(values))
91
+
92
+ Arel::Nodes::Equality.new(count, values.size)
93
+ }
94
+
95
+ scope :"with_any_#{method_name}", ->(*items) {
96
+ where overlap_query.call(items)
97
+ }
98
+
99
+ scope :"with_all_#{method_name}", ->(*items) {
100
+ where contains_query.call(items)
101
+ }
102
+
103
+ scope :"without_any_#{method_name}", ->(*items) {
104
+ where.not overlap_query.call(items)
105
+ }
106
+
107
+ scope :"without_all_#{method_name}", ->(*items) {
108
+ where.not contains_query.call(items)
109
+ }
110
+ end
111
+
112
+ def define_array_column_writer(column_name)
113
+ define_method :"#{column_name}=" do |value|
114
+ super(self.class.array_columns_sanitize_list(value))
115
+ end
116
+ end
117
+
118
+ def define_array_column_predicates(method_name, column_name)
119
+ define_method :"has_any_#{method_name}?" do |*values|
120
+ values = self.class.array_columns_sanitize_list(values)
121
+ existing = self.class.array_columns_sanitize_list(self[column_name])
122
+
123
+ (values & existing).present?
124
+ end
125
+
126
+ define_method :"has_all_#{method_name}?" do |*values|
127
+ values = self.class.array_columns_sanitize_list(values)
128
+ existing = self.class.array_columns_sanitize_list(self[column_name])
129
+
130
+ (values & existing).size == values.size
131
+ end
132
+
133
+ alias_method :"has_#{method_name.singularize}?", :"has_all_#{method_name}?"
134
+ end
135
+ end
136
+ end
137
+ end
@@ -1,3 +1,3 @@
1
1
  module SQLiteTypes
2
- VERSION = "0.3.3"
2
+ VERSION = "0.4.0"
3
3
  end
data/sig/sqlite_types.rbs CHANGED
@@ -16,6 +16,9 @@ module SQLiteTypes
16
16
  def force_equality?: (untyped value) -> bool
17
17
  end
18
18
 
19
+ module ArrayColumns
20
+ end
21
+
19
22
  class Interval < ActiveRecord::Type::Value
20
23
  def serialize: (untyped value) -> untyped
21
24
  def type_cast_for_schema: (untyped value) -> String
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activerecord-sqlite-types
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.3
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Wojtek Wrona
@@ -83,6 +83,7 @@ files:
83
83
  - lib/generators/sqlite_types/migration/templates/migration.rb.tt
84
84
  - lib/sqlite_types.rb
85
85
  - lib/sqlite_types/array.rb
86
+ - lib/sqlite_types/array_columns.rb
86
87
  - lib/sqlite_types/interval.rb
87
88
  - lib/sqlite_types/ip_address.rb
88
89
  - lib/sqlite_types/migration_helpers.rb