active_record-virtual_joins 0.1.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: 59d1d1b0ae151d9115e013a4915f52555327779289019f736dbb551a187ad6f1
4
+ data.tar.gz: c1b133959d7ff43e37fe86e3728625bfaafdd939cfc0053d6aff7e935f57f751
5
+ SHA512:
6
+ metadata.gz: cb99c980f83b67ec6ad6332946407dd59dc2c0795b3f2856fedbb0e3ef7ccec9fd687ad0e438240d3073877fe963429a001ede9111998dd91e5ac9387af70275
7
+ data.tar.gz: de20998770a734153f1ffa2149990b9fe7540ebcc55a87c30670210d446bc367f3e2e9ed0255dd24880b6e978e3c0e157dd4754788efde72cec24446bc8c9c4a
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2026 Minty Fresh
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,101 @@
1
+ # ActiveRecord::VirtualJoins
2
+
3
+ Joins a list of Ruby hashes to the records of an ActiveRecord relation, as a virtual table.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem 'active_record-virtual_joins', github: 'mintyfresh/active_record-virtual_joins'
11
+ ```
12
+
13
+ And then execute:
14
+
15
+ ```bash
16
+ $ bundle
17
+ ```
18
+
19
+ Then extend your models with `ActiveRecord::VirtualJoins` (typically in `ApplicationRecord`, so all models get it):
20
+
21
+ ```ruby
22
+ class ApplicationRecord < ActiveRecord::Base
23
+ primary_abstract_class
24
+
25
+ extend ActiveRecord::VirtualJoins
26
+ end
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ `virtual_joins` takes the column to join by, the list of hashes, and the types of the other columns.
32
+ The values are left-joined to the records by the join column, and selected onto them:
33
+
34
+ ```ruby
35
+ scores = [{ id: 1, score: 10 }, { id: 2, score: 20 }]
36
+
37
+ users = User.virtual_joins(:id, scores, score: :integer)
38
+ # SELECT "users".*, "users_id_json"."score" FROM "users"
39
+ # LEFT OUTER JOIN json_to_recordset('[{"id":1,"score":10},{"id":2,"score":20}]'::json)
40
+ # AS "users_id_json"("score" integer, "id" bigint)
41
+ # ON "users"."id" = "users_id_json"."id"
42
+
43
+ users.find(1).score # => 10
44
+ users.find(3).score # => nil
45
+ ```
46
+
47
+ Records without matching values are kept, with null values.
48
+ Keys of the hashes that aren't columns of the virtual table are ignored, and missing keys are null.
49
+
50
+ The values can be used in the rest of the query, by the name of the virtual table
51
+ (which defaults to the table and join column, eg. `users_id_json`):
52
+
53
+ ```ruby
54
+ User.virtual_joins(:id, scores, score: :integer).where('users_id_json.score > 15').order('users_id_json.score')
55
+
56
+ User.virtual_joins(:id, scores, join_table_name: :scores, score: :integer).order('scores.score')
57
+ ```
58
+
59
+ It can be called on any relation (including associations), and keeps the relation's conditions and projection:
60
+
61
+ ```ruby
62
+ user.posts.where(published: true).virtual_joins(:slug, views, views: :integer)
63
+ User.select(:id).virtual_joins(:id, scores, score: :integer) # selects only users.id and the score
64
+ ```
65
+
66
+ ### Types
67
+
68
+ Types can be given as ActiveRecord types (eg. `:integer`, `:string`), which are converted to the database's types,
69
+ or as SQL types (eg. `'decimal(10, 2)'`), which are used as they are.
70
+ The type of the join column defaults to the type of the model's column, but can be given like any other column.
71
+
72
+ Note that `:decimal` uses the database's default precision, which is a scale of 0 on MySQL
73
+ (so use an SQL type like `'decimal(10, 2)'` for fractional values).
74
+
75
+ ### Databases
76
+
77
+ PostgreSQL (`json_to_recordset`), MySQL (`JSON_TABLE`), and SQLite (`json_each`) are supported.
78
+
79
+ SQLite has no column types to convert the values to, so values are read as their JSON types
80
+ (eg. strings rather than times, and `1`/`0` rather than booleans).
81
+
82
+ ### Sorbet
83
+
84
+ A Tapioca DSL compiler is included, which adds `virtual_joins` to the relations generated for models
85
+ extended by `ActiveRecord::VirtualJoins`. It's loaded automatically by `bin/tapioca dsl`.
86
+
87
+ ## Development
88
+
89
+ The specs run against a dummy Rails application in `spec/dummy`, using SQLite by default:
90
+
91
+ ```bash
92
+ $ bin/rspec
93
+ $ bin/rubocop
94
+ ```
95
+
96
+ PostgreSQL and MySQL are selected with the `DATABASE_ADAPTER` and `DATABASE_URL` environment variables
97
+ (see `.github/workflows/ci.yml`).
98
+
99
+ ## License
100
+
101
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+ require 'bundler/gem_tasks'
5
+ require 'rspec/core/rake_task'
6
+ require 'rubocop/rake_task'
7
+
8
+ RSpec::Core::RakeTask.new(:spec)
9
+ RuboCop::RakeTask.new
10
+
11
+ task default: %i[spec rubocop]
@@ -0,0 +1,8 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module ActiveRecord
5
+ module VirtualJoins
6
+ VERSION = '0.1.0'
7
+ end
8
+ end
@@ -0,0 +1,128 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module ActiveRecord
5
+ module VirtualJoins
6
+ # A table of values decoded from JSON by the database, with the SQL to join it to a model's table:
7
+ # * PostgreSQL - `json_to_recordset`
8
+ # * MySQL - `JSON_TABLE`
9
+ # * SQLite - `json_each` (which doesn't convert the values to the types of the columns)
10
+ class VirtualTable
11
+ #: String
12
+ attr_reader :name
13
+
14
+ #: (
15
+ #| singleton(ActiveRecord::Base) model,
16
+ #| Symbol | String name,
17
+ #| Symbol | String join_column_name,
18
+ #| Hash[Symbol | String, Symbol | String] column_types
19
+ #| ) -> void
20
+ def initialize(model, name, join_column_name, column_types)
21
+ @model = model
22
+ @name = name.to_s
23
+ @join_column_name = join_column_name.to_s
24
+ @column_types = column_types.transform_keys(&:to_s)
25
+ @column_types[@join_column_name] ||= join_column_type
26
+ end
27
+
28
+ # Joins the virtual table to the model's table, by the join column.
29
+ #
30
+ #: (Enumerable[Hash[Symbol | String, untyped]] values) -> String
31
+ def join_sql(values)
32
+ @model.with_connection do |connection|
33
+ source = source_sql(connection)
34
+ condition = "#{@model.quoted_table_name}.#{connection.quote_column_name(@join_column_name)} = " \
35
+ "#{qualified_column_name(connection, @join_column_name)}"
36
+
37
+ @model.sanitize_sql_array(
38
+ ["LEFT OUTER JOIN #{source} ON #{condition}", ActiveSupport::JSON.encode(values.to_a)]
39
+ )
40
+ end
41
+ end
42
+
43
+ # The columns of the virtual table, other than the join column (which is the same as the model's).
44
+ #
45
+ #: -> Array[Arel::Nodes::SqlLiteral]
46
+ def select_values
47
+ @model.with_connection do |connection|
48
+ @column_types.keys.excluding(@join_column_name).map do |column_name|
49
+ Arel.sql(qualified_column_name(connection, column_name))
50
+ end
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ #: -> String
57
+ def join_column_type
58
+ column = @model.columns_hash[@join_column_name]
59
+ return column.sql_type if column
60
+
61
+ raise ArgumentError, "The type of the join column `#{@join_column_name}` must be given, " \
62
+ "since it isn't a column of `#{@model.name}`."
63
+ end
64
+
65
+ # The table, which decodes the values from a JSON array of objects (given as a bind parameter).
66
+ #
67
+ #: (ActiveRecord::ConnectionAdapters::AbstractAdapter connection) -> String
68
+ def source_sql(connection)
69
+ case connection.adapter_name
70
+ when /postgres/i then postgresql_source_sql(connection)
71
+ when /mysql|trilogy/i then mysql_source_sql(connection)
72
+ when /sqlite/i then sqlite_source_sql(connection)
73
+ else
74
+ raise NotImplementedError, "virtual_joins isn't supported by the #{connection.adapter_name} adapter."
75
+ end
76
+ end
77
+
78
+ #: (ActiveRecord::ConnectionAdapters::AbstractAdapter connection) -> String
79
+ def postgresql_source_sql(connection)
80
+ columns = @column_types.map do |column_name, type|
81
+ "#{connection.quote_column_name(column_name)} #{sql_type(connection, type)}"
82
+ end
83
+
84
+ "json_to_recordset(?::json) AS #{connection.quote_table_name(name)}(#{columns.join(', ')})"
85
+ end
86
+
87
+ #: (ActiveRecord::ConnectionAdapters::AbstractAdapter connection) -> String
88
+ def mysql_source_sql(connection)
89
+ columns = @column_types.map do |column_name, type|
90
+ "#{connection.quote_column_name(column_name)} #{sql_type(connection, type)} " \
91
+ "PATH #{connection.quote(json_path(column_name))}"
92
+ end
93
+
94
+ "JSON_TABLE(?, '$[*]' COLUMNS(#{columns.join(', ')})) AS #{connection.quote_table_name(name)}"
95
+ end
96
+
97
+ #: (ActiveRecord::ConnectionAdapters::AbstractAdapter connection) -> String
98
+ def sqlite_source_sql(connection)
99
+ columns = @column_types.keys.map do |column_name|
100
+ "json_extract(value, #{connection.quote(json_path(column_name))}) " \
101
+ "AS #{connection.quote_column_name(column_name)}"
102
+ end
103
+
104
+ "(SELECT #{columns.join(', ')} FROM json_each(?)) AS #{connection.quote_table_name(name)}"
105
+ end
106
+
107
+ # ActiveRecord types are converted to the database's types (eg. `:string` to `character varying`),
108
+ # while SQL types are used as they are.
109
+ #
110
+ #: (ActiveRecord::ConnectionAdapters::AbstractAdapter connection, Symbol | String type) -> String
111
+ def sql_type(connection, type)
112
+ type.is_a?(Symbol) ? connection.type_to_sql(type) : type
113
+ end
114
+
115
+ # A path to a key of a JSON object (which is quoted, so that keys aren't parsed as paths).
116
+ #
117
+ #: (String column_name) -> String
118
+ def json_path(column_name)
119
+ "$.#{column_name.to_json}"
120
+ end
121
+
122
+ #: (ActiveRecord::ConnectionAdapters::AbstractAdapter connection, String column_name) -> String
123
+ def qualified_column_name(connection, column_name)
124
+ "#{connection.quote_table_name(name)}.#{connection.quote_column_name(column_name)}"
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,49 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require 'active_record'
5
+ require 'active_record/virtual_joins/version'
6
+
7
+ module ActiveRecord
8
+ # Joins lists of hashes to the records of a model and its subclasses, as virtual tables:
9
+ #
10
+ # class ApplicationRecord < ActiveRecord::Base
11
+ # extend ActiveRecord::VirtualJoins
12
+ # end
13
+ #
14
+ # users = User.virtual_joins(:id, [{ id: 1, score: 10 }, { id: 2, score: 20 }], score: :integer)
15
+ # users.find(1).score # => 10
16
+ module VirtualJoins
17
+ autoload :VirtualTable, 'active_record/virtual_joins/virtual_table'
18
+
19
+ # Left-joins a list of hashes as a virtual table, matched to records by the join column,
20
+ # and selects the other columns of the virtual table onto the records.
21
+ #
22
+ # The columns of the virtual table are given with their types, as ActiveRecord types (eg. `:integer`)
23
+ # or SQL types (eg. `'numeric(10, 2)'`). The type of the join column defaults to that of the model's column.
24
+ # Keys of the hashes that aren't columns of the virtual table are ignored, and missing keys are null.
25
+ #
26
+ # The records' own columns are selected, unless the relation already selects other columns.
27
+ #
28
+ #: (
29
+ #| Symbol | String join_column_name,
30
+ #| Enumerable[Hash[Symbol | String, untyped]] join_values,
31
+ #| ?join_table_name: Symbol | String,
32
+ #| **Symbol | String join_value_types
33
+ #| ) -> ActiveRecord::Relation
34
+ def virtual_joins(
35
+ join_column_name,
36
+ join_values,
37
+ join_table_name: "#{table_name}_#{join_column_name}_json",
38
+ **join_value_types
39
+ )
40
+ virtual_table = VirtualTable.new(self, join_table_name, join_column_name, join_value_types)
41
+
42
+ relation = all.joins(virtual_table.join_sql(join_values))
43
+ relation = relation.select(arel_table[Arel.star]) if relation.select_values.empty?
44
+
45
+ select_values = virtual_table.select_values
46
+ select_values.empty? ? relation : relation.select(*select_values)
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,69 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ return unless defined?(ActiveRecord::Base)
5
+
6
+ require 'tapioca/dsl/compilers/active_record_relations'
7
+
8
+ module Tapioca
9
+ module Dsl
10
+ module Compilers
11
+ # Adds `virtual_joins` to the relations generated by the ActiveRecordRelations compiler,
12
+ # for models extended by ActiveRecord::VirtualJoins.
13
+ # (Relations delegate it to their model, so it isn't otherwise known to be a relation method.)
14
+ # Compilers decorate a constant in order of their names, so this runs after ActiveRecordRelations.
15
+ #
16
+ #: [ConstantType = singleton(::ActiveRecord::Base)]
17
+ class ActiveRecordVirtualJoins < Compiler
18
+ include Helpers::ActiveRecordConstantsHelper
19
+
20
+ # @override
21
+ #: -> void
22
+ def decorate
23
+ return unless compiler_enabled?('ActiveRecordRelations')
24
+
25
+ root.create_path(constant) do |model|
26
+ model.create_module(RelationMethodsModuleName) do |mod|
27
+ create_virtual_joins_method(mod, RelationClassName)
28
+ end
29
+
30
+ model.create_module(AssociationRelationMethodsModuleName) do |mod|
31
+ create_virtual_joins_method(mod, AssociationRelationClassName)
32
+ end
33
+ end
34
+ end
35
+
36
+ class << self
37
+ # @override
38
+ #: -> Enumerable[Module[top]]
39
+ def gather_constants
40
+ ActiveRecordRelations.gather_constants.grep(::ActiveRecord::VirtualJoins)
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ #: (RBI::Scope scope, String return_type) -> void
47
+ def create_virtual_joins_method(scope, return_type)
48
+ scope.create_method(
49
+ 'virtual_joins',
50
+ parameters: [
51
+ create_param('join_column_name', type: 'T.any(Symbol, String)'),
52
+ create_param('join_values', type: 'T::Enumerable[T::Hash[T.any(Symbol, String), T.untyped]]'),
53
+ create_kw_opt_param('join_table_name', type: 'T.any(Symbol, String)', default: default_join_table_name),
54
+ create_kw_rest_param('join_value_types', type: 'T.any(Symbol, String)'),
55
+ ],
56
+ return_type:
57
+ )
58
+ end
59
+
60
+ # The default depends on the join column, so it's given as the expression which computes it.
61
+ #
62
+ #: -> String
63
+ def default_join_table_name
64
+ '"#{table_name}_#{join_column_name}_json"' # rubocop:disable Lint/InterpolationCheck
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
metadata ADDED
@@ -0,0 +1,65 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: active_record-virtual_joins
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Minty Fresh
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: '8.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '8.1'
26
+ description: Joins a list of hashes to the records of an ActiveRecord relation as
27
+ a virtual table, selecting its values onto the records.
28
+ email:
29
+ - 7896757+mintyfresh@users.noreply.github.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - MIT-LICENSE
35
+ - README.md
36
+ - Rakefile
37
+ - lib/active_record/virtual_joins.rb
38
+ - lib/active_record/virtual_joins/version.rb
39
+ - lib/active_record/virtual_joins/virtual_table.rb
40
+ - lib/tapioca/dsl/compilers/active_record_virtual_joins.rb
41
+ homepage: https://github.com/mintyfresh/active_record-virtual_joins
42
+ licenses:
43
+ - MIT
44
+ metadata:
45
+ homepage_uri: https://github.com/mintyfresh/active_record-virtual_joins
46
+ source_code_uri: https://github.com/mintyfresh/active_record-virtual_joins
47
+ rubygems_mfa_required: 'true'
48
+ rdoc_options: []
49
+ require_paths:
50
+ - lib
51
+ required_ruby_version: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: '3.2'
56
+ required_rubygems_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ requirements: []
62
+ rubygems_version: 4.0.20
63
+ specification_version: 4
64
+ summary: Join lists of Ruby hashes to ActiveRecord relations
65
+ test_files: []