graphsql 0.4.0 → 0.5.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: 6cf0933b75d22d60adbda095d91985202111a72476a1d418b9b6b6c19a9e26c3
4
- data.tar.gz: dc5823be1f0741913aa1166be8c4826bd7744623d5fd578457a1cc252ec0b58d
3
+ metadata.gz: 7bbfab985975b92c0a66baf041c9d7aba13301ba4ac7003e1b39deb01d397c09
4
+ data.tar.gz: f01d88e8126cb071191ab3870a3575436b09e2f43c6e82339c3902ad7fcd3f91
5
5
  SHA512:
6
- metadata.gz: d55b842c33c1bebdfdaec2cf0fe8939f2ac7daebac8d61e4c1c85cd57b10120fec5acb7e937ffbfb50c73cc23055e94a2df3ecd009ab40dcd8a88d5f9b4c0356
7
- data.tar.gz: 76c2e45232b2ee3788cb313394f8263be8a51f94e9bc257a9f0275ac1e2e1e3aaaee3386173217632d0923adbcb407ff33827e0f4a09e5efe2e5f92ec3b39f80
6
+ metadata.gz: ce07ab33b2abf0ca40d1b4bf66a3bb0cbd4a617332dd3654ec20bdecc258dde37765959499dbb63f9eb7749c736457a11501e601e91187607b7e86b04697698a
7
+ data.tar.gz: 1028c250b79bd3049393b306881e653e8a2dafe3f8a2bcee2d08f0ce072b5470f1450f5a3b06331a376129bc0ea4d51528a39a9e4aa0dce3e6f6094de8660bce
data/CHANGELOG.md ADDED
@@ -0,0 +1,85 @@
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.0] - 2026-07-31
11
+
12
+ ### Added
13
+
14
+ - `rails generate graphsql:type ModelName` generator: introspects an
15
+ already-migrated model's real columns/associations and scaffolds a
16
+ matching type (`graphsql_column`/`graphsql_association`/`field` for each,
17
+ a `lookahead:`-driven resolver method for collection associations), and
18
+ wires a matching collection field/resolver into the app's query type.
19
+ See README "Generator".
20
+
21
+ ## [0.4.0] - 2026-07-30
22
+
23
+ ### Changed
24
+
25
+ - Gem package name renamed from `GraphSQL` to `graphsql`, matching rubygems
26
+ convention (`graphql`/`GraphQL`, `activerecord`/`ActiveRecord` are all
27
+ lowercase packages with capitalized Ruby modules). The `GraphSQL` Ruby
28
+ module is unchanged. Breaking for any Gemfile still referencing the old
29
+ capitalized package name.
30
+
31
+ ## [0.3.3] - 2026-07-30
32
+
33
+ ### Fixed
34
+
35
+ - Mapping the same underlying association under two nestable GraphQL field
36
+ aliases on one type (e.g. `owner`/`employer` both -> `belongs_to
37
+ :organization`) no longer silently applies only one alias's column-limited
38
+ scope to both, dropping the other alias's requested fields. Now raises
39
+ `GraphSQL::AliasedAssociationError` immediately instead.
40
+
41
+ ## [0.3.2] - 2026-07-30
42
+
43
+ ### Added
44
+
45
+ - `graphsql_column`/`required_columns:` referencing something that isn't a
46
+ real column on the model (a typo, a Ruby method, a virtual `attribute`
47
+ with no DB backing) now raises `GraphSQL::UnknownColumnError` naming the
48
+ type, field, and column — instead of a raw `ActiveRecord::StatementInvalid`
49
+ surfacing later, deep in Arel, once a query actually requested that field.
50
+
51
+ ## [0.3.1] - 2026-07-30
52
+
53
+ ### Fixed
54
+
55
+ - Single-table inheritance models now retain their `inheritance_column` in
56
+ the generated `SELECT`, so rows instantiate as the correct subclass.
57
+ Previously every row silently came back as the STI base class, with no
58
+ error at all.
59
+
60
+ ## [0.3.0] - 2026-07-30
61
+
62
+ ### Fixed
63
+
64
+ - A bare `.count` on a relation returned by `GraphSQL.resolve` no longer
65
+ raises `ActiveRecord::StatementInvalid` (Rails' default `.count` column
66
+ inference builds an invalid multi-column `COUNT(...)` once more than one
67
+ column is selected, which every `GraphSQL.resolve`'d relation has). It now
68
+ counts by the primary key by default; `.count(:column)` and
69
+ `.count { |r| ... }` still pass through unchanged.
70
+
71
+ ## [0.2.0] - 2026-07-25
72
+
73
+ ### Fixed
74
+
75
+ - `graphsql_association`'s `to:` is now actually consulted for nested
76
+ column-limiting, recursing at arbitrary depth (`owner { organization {
77
+ name } }`) instead of only ever optimizing the top-level type's columns.
78
+ - `has_many` column-limiting no longer silently returns an empty association
79
+ array when the child-side foreign key isn't itself GraphQL-mapped.
80
+ - A plain (non-nestable) association requested alongside a nestable one on
81
+ the same type is no longer dropped from preloading.
82
+
83
+ ## [0.1.0] - 2026-07-14
84
+
85
+ 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.0"
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.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dot Matrix
@@ -93,6 +93,20 @@ dependencies:
93
93
  - - ">="
94
94
  - !ruby/object:Gem::Version
95
95
  version: '0'
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'
96
110
  description: Selects mapped ActiveRecord columns and preloads mapped associations
97
111
  from GraphQL lookaheads.
98
112
  email:
@@ -101,8 +115,11 @@ executables: []
101
115
  extensions: []
102
116
  extra_rdoc_files: []
103
117
  files:
118
+ - CHANGELOG.md
104
119
  - LICENSE.txt
105
120
  - README.md
121
+ - lib/generators/graphsql/type/templates/type.rb.tt
122
+ - lib/generators/graphsql/type/type_generator.rb
106
123
  - lib/graphsql.rb
107
124
  - lib/graphsql/aliased_association_error.rb
108
125
  - lib/graphsql/mapping.rb
@@ -115,6 +132,7 @@ licenses:
115
132
  metadata:
116
133
  source_code_uri: https://gitlab.com/maqstaq/graphsql
117
134
  bug_tracker_uri: https://gitlab.com/maqstaq/graphsql/-/issues
135
+ changelog_uri: https://gitlab.com/maqstaq/graphsql/-/blob/main/CHANGELOG.md
118
136
  rdoc_options: []
119
137
  require_paths:
120
138
  - lib