graphsql 0.4.0 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6cf0933b75d22d60adbda095d91985202111a72476a1d418b9b6b6c19a9e26c3
4
- data.tar.gz: dc5823be1f0741913aa1166be8c4826bd7744623d5fd578457a1cc252ec0b58d
3
+ metadata.gz: 7a69af6d7f58b9a71a39a4e760de0505dddad7311298f71bb9447d4635718eb5
4
+ data.tar.gz: 4edaf48b75cdb861eb37203c889fd2bea8bc2c945e8283ccfc6b2a514ff334c0
5
5
  SHA512:
6
- metadata.gz: d55b842c33c1bebdfdaec2cf0fe8939f2ac7daebac8d61e4c1c85cd57b10120fec5acb7e937ffbfb50c73cc23055e94a2df3ecd009ab40dcd8a88d5f9b4c0356
7
- data.tar.gz: 76c2e45232b2ee3788cb313394f8263be8a51f94e9bc257a9f0275ac1e2e1e3aaaee3386173217632d0923adbcb407ff33827e0f4a09e5efe2e5f92ec3b39f80
6
+ metadata.gz: 15997387c5c563f5aab7ffee76305ebbb2730b129ccb0609e91b916983fc78f532ea16409b059f2673790c4f4e0467f2f46a1043da58a68297fa6062b0122c49
7
+ data.tar.gz: 1dfe67fcb6353a3a32ef3622e192c6cdff64c065f064298318b80ffa96fbf69fcdc9f175eb7ef58e9574a026d249aedf68e260609064d69d261accb8696caed3
data/CHANGELOG.md ADDED
@@ -0,0 +1,99 @@
1
+ # Changelog
2
+
3
+ All notable changes to this gem are documented here. Loosely follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versions before
5
+ 0.4.0 were never published to rubygems.org — this repo graduated out of the
6
+ MaqStaq monorepo (full commit history preserved) ahead of the first real
7
+ release. See `ROADMAP.md` for the full writeup behind each fix below,
8
+ including how it was confirmed and what was checked and ruled out.
9
+
10
+ ## [0.5.1] - 2026-07-31
11
+
12
+ ### Changed
13
+
14
+ - Friendlier gemspec `summary`/`description` — the old description read like
15
+ an internal code comment ("Selects mapped ActiveRecord columns and
16
+ preloads mapped associations from GraphQL lookaheads.").
17
+
18
+ ### Infrastructure
19
+
20
+ - Added `.gitlab-ci.yml` (test suite, `gem build` sanity check,
21
+ SAST/secret-detection) — nothing ran this gem's tests automatically since
22
+ it graduated out of the monorepo's own CI matrix.
23
+
24
+ ## [0.5.0] - 2026-07-31
25
+
26
+ ### Added
27
+
28
+ - `rails generate graphsql:type ModelName` generator: introspects an
29
+ already-migrated model's real columns/associations and scaffolds a
30
+ matching type (`graphsql_column`/`graphsql_association`/`field` for each,
31
+ a `lookahead:`-driven resolver method for collection associations), and
32
+ wires a matching collection field/resolver into the app's query type.
33
+ See README "Generator".
34
+
35
+ ## [0.4.0] - 2026-07-30
36
+
37
+ ### Changed
38
+
39
+ - Gem package name renamed from `GraphSQL` to `graphsql`, matching rubygems
40
+ convention (`graphql`/`GraphQL`, `activerecord`/`ActiveRecord` are all
41
+ lowercase packages with capitalized Ruby modules). The `GraphSQL` Ruby
42
+ module is unchanged. Breaking for any Gemfile still referencing the old
43
+ capitalized package name.
44
+
45
+ ## [0.3.3] - 2026-07-30
46
+
47
+ ### Fixed
48
+
49
+ - Mapping the same underlying association under two nestable GraphQL field
50
+ aliases on one type (e.g. `owner`/`employer` both -> `belongs_to
51
+ :organization`) no longer silently applies only one alias's column-limited
52
+ scope to both, dropping the other alias's requested fields. Now raises
53
+ `GraphSQL::AliasedAssociationError` immediately instead.
54
+
55
+ ## [0.3.2] - 2026-07-30
56
+
57
+ ### Added
58
+
59
+ - `graphsql_column`/`required_columns:` referencing something that isn't a
60
+ real column on the model (a typo, a Ruby method, a virtual `attribute`
61
+ with no DB backing) now raises `GraphSQL::UnknownColumnError` naming the
62
+ type, field, and column — instead of a raw `ActiveRecord::StatementInvalid`
63
+ surfacing later, deep in Arel, once a query actually requested that field.
64
+
65
+ ## [0.3.1] - 2026-07-30
66
+
67
+ ### Fixed
68
+
69
+ - Single-table inheritance models now retain their `inheritance_column` in
70
+ the generated `SELECT`, so rows instantiate as the correct subclass.
71
+ Previously every row silently came back as the STI base class, with no
72
+ error at all.
73
+
74
+ ## [0.3.0] - 2026-07-30
75
+
76
+ ### Fixed
77
+
78
+ - A bare `.count` on a relation returned by `GraphSQL.resolve` no longer
79
+ raises `ActiveRecord::StatementInvalid` (Rails' default `.count` column
80
+ inference builds an invalid multi-column `COUNT(...)` once more than one
81
+ column is selected, which every `GraphSQL.resolve`'d relation has). It now
82
+ counts by the primary key by default; `.count(:column)` and
83
+ `.count { |r| ... }` still pass through unchanged.
84
+
85
+ ## [0.2.0] - 2026-07-25
86
+
87
+ ### Fixed
88
+
89
+ - `graphsql_association`'s `to:` is now actually consulted for nested
90
+ column-limiting, recursing at arbitrary depth (`owner { organization {
91
+ name } }`) instead of only ever optimizing the top-level type's columns.
92
+ - `has_many` column-limiting no longer silently returns an empty association
93
+ array when the child-side foreign key isn't itself GraphQL-mapped.
94
+ - A plain (non-nestable) association requested alongside a nestable one on
95
+ the same type is no longer dropped from preloading.
96
+
97
+ ## [0.1.0] - 2026-07-14
98
+
99
+ Initial version, added to the MaqStaq monorepo.
data/README.md CHANGED
@@ -1,9 +1,32 @@
1
1
  # GraphSQL
2
2
 
3
+ [![Gem Version](https://badge.fury.io/rb/graphsql.svg?icon=si%3Arubygems)](https://badge.fury.io/rb/graphsql)
4
+
3
5
  GraphSQL maps GraphQL type fields to ActiveRecord columns and associations,
4
6
  then applies a GraphQL lookahead to an ActiveRecord relation. It keeps query
5
7
  types declarative while avoiding unnecessary SQL columns and common N+1 reads.
6
8
 
9
+ ## Installation
10
+
11
+ Published on [rubygems.org](https://rubygems.org/gems/graphsql) as `graphsql`
12
+ (the Ruby module is `GraphSQL`, capitalized — only the package name is
13
+ lowercase, matching the `graphql`/`GraphQL` and `activerecord`/`ActiveRecord`
14
+ convention). Add it to your Gemfile:
15
+
16
+ ```ruby
17
+ gem "graphsql"
18
+ ```
19
+
20
+ ```bash
21
+ bundle install
22
+ ```
23
+
24
+ Or without Bundler:
25
+
26
+ ```bash
27
+ gem install graphsql
28
+ ```
29
+
7
30
  ## Usage
8
31
 
9
32
  ```ruby
@@ -117,6 +140,52 @@ belongs on the *pre-resolve* relation, not chained onto `GraphSQL.resolve`'s
117
140
  return value — `GraphSQL.resolve` is meant to be the terminal step right
118
141
  before returning the field's value.
119
142
 
143
+ ## Generator
144
+
145
+ In a Rails app, `rails generate graphsql:type ModelName` introspects an
146
+ **already-migrated** model's real columns and associations and scaffolds a
147
+ matching type — this reads the live schema via ActiveRecord, it doesn't
148
+ guess from a migration file, so create the model (and run its migration)
149
+ first:
150
+
151
+ ```bash
152
+ rails generate graphsql:type Persona
153
+ ```
154
+
155
+ This writes `app/graphql/types/persona_type.rb` with a `graphsql_column` +
156
+ `field` pair for every real column (skipping the primary key, the STI
157
+ discriminator column if any, and `belongs_to` foreign key columns — those
158
+ are exposed through the association's own field instead), and a
159
+ `graphsql_association` + `field` pair for every association, guessing
160
+ `to: Types::<TargetClass>Type` for every non-polymorphic one. Collection
161
+ (`has_many`) associations also get a `lookahead:`-driven resolver method;
162
+ `belongs_to`/`has_one` don't need one — they're preloaded by whatever calls
163
+ `GraphSQL.resolve` on the *containing* type, and graphql-ruby just reads
164
+ `object.<association>` by convention.
165
+
166
+ It also wires a matching collection field + resolver into your query type
167
+ (`app/graphql/types/query_type.rb` by default), via Thor's
168
+ `inject_into_class` — it lands right after the class's opening line, not
169
+ necessarily grouped with existing `field` calls, since that's as precise as
170
+ text injection gets. Review the diff.
171
+
172
+ Options:
173
+
174
+ ```bash
175
+ rails generate graphsql:type Persona --type-namespace=GraphQL::Types # default: Types
176
+ rails generate graphsql:type Persona --base-class=Types::BaseNode # default: "<type_namespace>::BaseObject"
177
+ rails generate graphsql:type Persona --query-type-path=app/graphql/types/root_query.rb
178
+ rails generate graphsql:type Persona --query-type-class-name=RootQuery # default: QueryType
179
+ rails generate graphsql:type Persona --skip-query # only generate the type, don't touch the query type
180
+ ```
181
+
182
+ A polymorphic association gets a `graphsql_association` line with no `to:`
183
+ (matching the gem's own documented fallback) but no `field`/resolver —
184
+ there's no single GraphQL type to point at without a Union/Interface, add
185
+ that by hand. Everything else the generator can't confidently guess is
186
+ meant to be edited afterward, not configured away — it's a starting point,
187
+ not a promise of a finished type.
188
+
120
189
  ## Development
121
190
 
122
191
  ```bash
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module <%= type_namespace %>
4
+ class <%= class_name %>Type < <%= base_class %>
5
+ include GraphSQL::Mapping
6
+
7
+ graphsql_model <%= model_class.name %>
8
+ <% mapped_columns.each do |column| -%>
9
+ graphsql_column :<%= column.name %>
10
+ <% end -%>
11
+ <% mapped_associations.each do |assoc| -%>
12
+ <% if assoc.to_type -%>
13
+ graphsql_association :<%= assoc.name %>, to: <%= assoc.to_type %>
14
+ <% else -%>
15
+ graphsql_association :<%= assoc.name %> # polymorphic — no single GraphQL type to guess; map/field this by hand
16
+ <% end -%>
17
+ <% end -%>
18
+
19
+ field :id, ID, null: false
20
+ <% mapped_columns.each do |column| -%>
21
+ field :<%= column.name %>, <%= column.graphql_type %>, null: <%= column.nullable %>
22
+ <% end -%>
23
+ <% mapped_associations.each do |assoc| -%>
24
+ <% next unless assoc.to_type -%>
25
+ field :<%= assoc.name %>, <%= association_field_type(assoc) %>, null: <%= assoc.nullable %><%= assoc.collection ? ", extras: [:lookahead]" : "" %>
26
+ <% end -%>
27
+ <% if resolver_associations.any? -%>
28
+
29
+ <% resolver_associations.each do |assoc| -%>
30
+ def <%= assoc.name %>(lookahead:)
31
+ GraphSQL.resolve(object.<%= assoc.name %>, lookahead: lookahead, type: <%= assoc.to_type %>)
32
+ end
33
+ <% end -%>
34
+ <% end -%>
35
+ end
36
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/named_base"
5
+
6
+ module GraphSQL
7
+ module Generators
8
+ # rails generate graphsql:type Persona
9
+ #
10
+ # Introspects an already-migrated ActiveRecord model's columns and
11
+ # associations and emits a matching GraphSQL::Mapping type — every
12
+ # column gets a graphsql_column + field pair, every association gets a
13
+ # graphsql_association + field pair (plus a lookahead-driven resolver
14
+ # method for collection associations), and `to:` is guessed for every
15
+ # non-polymorphic association as "<type_namespace>::<TargetClass>Type".
16
+ # Also wires a matching collection field + resolver into the app's query
17
+ # type via Thor's inject_into_class, unless --skip-query.
18
+ #
19
+ # This only works against a model whose table already exists — it reads
20
+ # real column/association metadata via ActiveRecord, it doesn't infer
21
+ # anything from a migration file. Generate the model (and run its
22
+ # migration) first, then generate its type.
23
+ class TypeGenerator < Rails::Generators::NamedBase
24
+ source_root File.expand_path("templates", __dir__)
25
+
26
+ MappedColumn = Data.define(:name, :graphql_type, :nullable)
27
+ MappedAssociation = Data.define(:name, :to_type, :collection, :nullable)
28
+
29
+ SCALAR_TYPES = {
30
+ "string" => "String", "text" => "String", "citext" => "String",
31
+ "integer" => "Integer", "bigint" => "Integer",
32
+ "float" => "Float", "decimal" => "Float",
33
+ "boolean" => "Boolean",
34
+ "date" => "GraphQL::Types::ISO8601Date",
35
+ "datetime" => "GraphQL::Types::ISO8601DateTime", "timestamp" => "GraphQL::Types::ISO8601DateTime",
36
+ "json" => "GraphQL::Types::JSON", "jsonb" => "GraphQL::Types::JSON"
37
+ }.freeze
38
+
39
+ class_option :type_namespace, type: :string, default: "Types",
40
+ desc: "Module namespace the generated type (and guessed association `to:` types) live under"
41
+ class_option :base_class, type: :string, default: nil,
42
+ desc: 'Base class for the generated type (default: "<type_namespace>::BaseObject")'
43
+ class_option :query_type_path, type: :string, default: "app/graphql/types/query_type.rb",
44
+ desc: "Path to the query type file to wire a collection field/resolver into"
45
+ class_option :query_type_class_name, type: :string, default: "QueryType",
46
+ desc: "Unqualified class name inject_into_class matches inside --query-type-path"
47
+ class_option :skip_query, type: :boolean, default: false,
48
+ desc: "Only generate the type file — don't touch the query type"
49
+
50
+ def create_type_file
51
+ template "type.rb.tt", File.join("app/graphql/types", "#{file_name}_type.rb")
52
+ end
53
+
54
+ def add_query_field
55
+ return if options[:skip_query]
56
+
57
+ unless File.exist?(File.join(destination_root, query_type_path))
58
+ say_status :skip,
59
+ "#{query_type_path} not found (pass --query-type-path or add the field by hand)", :yellow
60
+ return
61
+ end
62
+
63
+ inject_into_class query_type_path, options[:query_type_class_name], query_field_snippet
64
+ end
65
+
66
+ private
67
+
68
+ def model_class
69
+ @model_class ||= class_name.constantize
70
+ rescue NameError
71
+ raise Thor::Error,
72
+ "graphsql:type couldn't load a `#{class_name}` model — make sure the model class and its " \
73
+ "migration exist before generating its type (this generator introspects the real schema, " \
74
+ "it doesn't guess from a migration file)"
75
+ end
76
+
77
+ def type_namespace
78
+ options[:type_namespace]
79
+ end
80
+
81
+ def base_class
82
+ options[:base_class] || "#{type_namespace}::BaseObject"
83
+ end
84
+
85
+ def query_type_path
86
+ options[:query_type_path]
87
+ end
88
+
89
+ def guessed_type_for(klass)
90
+ "#{type_namespace}::#{klass.name}Type"
91
+ end
92
+
93
+ # Every non-PK, non-STI-discriminator, non-belongs_to-foreign-key
94
+ # column on the model — those three are either handled automatically
95
+ # by GraphSQL.resolve (PK, STI column) or exposed through the
96
+ # association's own field instead of as a raw id/type scalar (FK).
97
+ def mapped_columns
98
+ @mapped_columns ||= begin
99
+ belongs_to_fks = model_class.reflect_on_all_associations(:belongs_to).flat_map do |reflection|
100
+ reflection.polymorphic? ? [reflection.foreign_key, reflection.foreign_type] : [reflection.foreign_key]
101
+ end
102
+ skip = Set.new(belongs_to_fks) << model_class.primary_key << sti_column_name
103
+
104
+ model_class.columns.reject { |column| skip.include?(column.name) }.map do |column|
105
+ MappedColumn.new(
106
+ name: column.name.to_sym,
107
+ graphql_type: SCALAR_TYPES.fetch(column.type.to_s, "String"),
108
+ nullable: column.null
109
+ )
110
+ end
111
+ end
112
+ end
113
+
114
+ def sti_column_name
115
+ model_class.inheritance_column if model_class.columns_hash.key?(model_class.inheritance_column)
116
+ end
117
+
118
+ # `to:` is guessable for every association except polymorphic ones
119
+ # (no single target class to point at — reflection.klass itself would
120
+ # raise). :through associations still get a normal `to:` guess: the
121
+ # gem's own resolver already falls back to a plain full-row preload
122
+ # for :through on its own (see lib/graphsql/resolver.rb), so mapping
123
+ # one here is harmless, not incorrect.
124
+ def mapped_associations
125
+ @mapped_associations ||= model_class.reflect_on_all_associations.map do |reflection|
126
+ MappedAssociation.new(
127
+ name: reflection.name,
128
+ to_type: reflection.polymorphic? ? nil : guessed_type_for(reflection.klass),
129
+ collection: reflection.collection?,
130
+ nullable: association_nullable?(reflection)
131
+ )
132
+ end
133
+ end
134
+
135
+ def association_nullable?(reflection)
136
+ case reflection.macro
137
+ when :belongs_to then reflection.options[:optional] == true
138
+ when :has_one then true
139
+ else false # has_many: empty array, never null itself
140
+ end
141
+ end
142
+
143
+ def association_field_type(assoc)
144
+ assoc.collection ? "[#{assoc.to_type}]" : assoc.to_type
145
+ end
146
+
147
+ # Collection associations need their own lookahead-driven resolver
148
+ # method to apply column-limiting to what THEY return — belongs_to/
149
+ # has_one are already preloaded (full or column-limited) by the
150
+ # parent's own GraphSQL.resolve call and need no method of their own,
151
+ # graphql-ruby just calls object.<name> by convention.
152
+ def resolver_associations
153
+ mapped_associations.select { |assoc| assoc.collection && assoc.to_type }
154
+ end
155
+
156
+ def query_field_name
157
+ file_name.pluralize
158
+ end
159
+
160
+ def query_field_snippet
161
+ namespaced_type = guessed_type_for(model_class)
162
+ <<~RUBY
163
+
164
+ field :#{query_field_name}, [#{namespaced_type}], null: false, extras: [:lookahead]
165
+
166
+ def #{query_field_name}(lookahead:)
167
+ GraphSQL.resolve(#{model_class.name}.all, lookahead: lookahead, type: #{namespaced_type})
168
+ end
169
+ RUBY
170
+ end
171
+ end
172
+ end
173
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module GraphSQL
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: graphsql
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dot Matrix
@@ -93,16 +93,35 @@ dependencies:
93
93
  - - ">="
94
94
  - !ruby/object:Gem::Version
95
95
  version: '0'
96
- description: Selects mapped ActiveRecord columns and preloads mapped associations
97
- from GraphQL lookaheads.
96
+ - !ruby/object:Gem::Dependency
97
+ name: railties
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '6.1'
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '6.1'
110
+ description: Map your GraphQL type's fields to ActiveRecord columns and associations,
111
+ and GraphSQL turns each query's actual field selection into a right-sized SELECT
112
+ and preload automatically — no more guessing which columns and associations a resolver
113
+ needs to load.
98
114
  email:
99
115
  - matrix9180@proton.me
100
116
  executables: []
101
117
  extensions: []
102
118
  extra_rdoc_files: []
103
119
  files:
120
+ - CHANGELOG.md
104
121
  - LICENSE.txt
105
122
  - README.md
123
+ - lib/generators/graphsql/type/templates/type.rb.tt
124
+ - lib/generators/graphsql/type/type_generator.rb
106
125
  - lib/graphsql.rb
107
126
  - lib/graphsql/aliased_association_error.rb
108
127
  - lib/graphsql/mapping.rb
@@ -115,6 +134,7 @@ licenses:
115
134
  metadata:
116
135
  source_code_uri: https://gitlab.com/maqstaq/graphsql
117
136
  bug_tracker_uri: https://gitlab.com/maqstaq/graphsql/-/issues
137
+ changelog_uri: https://gitlab.com/maqstaq/graphsql/-/blob/main/CHANGELOG.md
118
138
  rdoc_options: []
119
139
  require_paths:
120
140
  - lib
@@ -131,5 +151,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
131
151
  requirements: []
132
152
  rubygems_version: 4.0.16
133
153
  specification_version: 4
134
- summary: GraphQL lookahead mapping for ActiveRecord relations.
154
+ summary: Turn a GraphQL query's field selection into a right-sized ActiveRecord query.
135
155
  test_files: []