active_postgrest 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: cbb8484a4d524bbe396c3cb4a87dade05a3e8628aa2e0703c84b749287a86e67
4
+ data.tar.gz: 8640a588f4fac91e890575794632d154cec30ab699aea4057f3ce1d4245ea7fa
5
+ SHA512:
6
+ metadata.gz: '097618ab9f67309599c73848566398f423c8d584f8844e603a9a25f845be8a359cb4f3e347de72c888c7b42b10ea30ded62e2f3418743394085d521b80d47fc7'
7
+ data.tar.gz: baef5f5829e50eef2d9f012a275d8391b73b4f269c5053c4286ff1290ca5593160da71cd6a12347a8fcd18151a82df7f6d1874b3f89ff2345d60f896b72254a3
data/LICENSE ADDED
@@ -0,0 +1,184 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship made available under
36
+ the License, as indicated by a copyright notice that is included in
37
+ or attached to the work (an example is provided in the Appendix below).
38
+
39
+ "Derivative Works" shall mean any work, whether in Source or Object
40
+ form, that is based on (or derived from) the Work and for which the
41
+ editorial revisions, annotations, elaborations, or other modifications
42
+ represent, as a whole, an original work of authorship. For the purposes
43
+ of this License, Derivative Works shall not include works that remain
44
+ separable from, or merely link (or bind by name) to the interfaces of,
45
+ the Work and Derivative Works thereof.
46
+
47
+ "Contribution" shall mean, as submitted to the Licensor for inclusion
48
+ in the Work by the copyright owner or by an individual or Legal Entity
49
+ authorized to submit on behalf of the copyright owner. For the purposes
50
+ of this definition, "submitted" means any form of electronic, verbal,
51
+ or written communication sent to the Licensor or its representatives,
52
+ including but not limited to communication on electronic mailing lists,
53
+ source code control systems, and issue tracking systems that are managed
54
+ by, or on behalf of, the Licensor for the purpose of discussing and
55
+ improving the Work, but excluding communication that is conspicuously
56
+ marked or designated in writing by the copyright owner as "Not a
57
+ Contribution."
58
+
59
+ "Contributor" shall mean Licensor and any Legal Entity on behalf of
60
+ whom a Contribution has been received by the Licensor and subsequently
61
+ incorporated within the Work.
62
+
63
+ 2. Grant of Copyright License. Subject to the terms and conditions of
64
+ this License, each Contributor hereby grants to You a perpetual,
65
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
66
+ copyright license to reproduce, prepare Derivative Works of,
67
+ publicly display, publicly perform, sublicense, and distribute the
68
+ Work and such Derivative Works in Source or Object form.
69
+
70
+ 3. Grant of Patent License. Subject to the terms and conditions of
71
+ this License, each Contributor hereby grants to You a perpetual,
72
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
73
+ (except as stated in this section) patent license to make, have made,
74
+ use, offer to sell, sell, import, and otherwise transfer the Work,
75
+ where such license applies only to those patent claims licensable
76
+ by such Contributor that are necessarily infringed by their
77
+ Contribution(s) alone or by the combination of their Contribution(s)
78
+ with the Work to which such Contribution(s) was submitted. If You
79
+ institute patent litigation against any entity (including a cross-claim
80
+ or counterclaim in a lawsuit) alleging that the Work or any
81
+ Contribution embodied within the Work constitutes direct or contributory
82
+ patent infringement, then any patent licenses granted to You under
83
+ this License for that Work shall terminate as of the date such
84
+ litigation is filed.
85
+
86
+ 4. Redistribution. You may reproduce and distribute copies of the
87
+ Work or Derivative Works thereof in any medium, with or without
88
+ modifications, and in Source or Object form, provided that You
89
+ meet the following conditions:
90
+
91
+ (a) You must give any other recipients of the Work or Derivative
92
+ Works a copy of this License; and
93
+
94
+ (b) You must cause any modified files to carry prominent notices
95
+ stating that You changed the files; and
96
+
97
+ (c) You must retain, in the Source form of any Derivative Works
98
+ that You distribute, all copyright, patent, trademark, and
99
+ attribution notices from the Source form of the Work,
100
+ excluding those notices that do not pertain to any part of
101
+ the Derivative Works; and
102
+
103
+ (d) If the Work includes a "NOTICE" text file as part of its
104
+ distribution, You must include a readable copy of the
105
+ attribution notices contained within such NOTICE file, in
106
+ at least one of the following places: within a NOTICE text
107
+ file distributed as part of the Derivative Works; within
108
+ the Source form or documentation, if provided along with the
109
+ Derivative Works; or, within a display generated by the
110
+ Derivative Works, if and wherever such third-party notices
111
+ normally appear. The contents of the NOTICE file are for
112
+ informational purposes only and do not modify the License.
113
+ You may add Your own attribution notices within Derivative
114
+ Works that You distribute, alongside or as an addendum to
115
+ the NOTICE text from the Work, provided that such additional
116
+ attribution notices cannot be construed as modifying the License.
117
+
118
+ You may add Your own license statement for Your modifications and
119
+ may provide additional grant of rights to use, copy, modify, merge,
120
+ publish, distribute, sublicense, and/or sell copies of the
121
+ Contribution, either on an as-is basis or with modifications, as You
122
+ see fit.
123
+
124
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
125
+ any Contribution intentionally submitted for inclusion in the Work
126
+ by You to the Licensor shall be under the terms and conditions of
127
+ this License, without any additional terms or conditions.
128
+ Notwithstanding the above, nothing herein shall supersede or modify
129
+ the terms of any separate license agreement you may have executed
130
+ with Licensor regarding such Contributions.
131
+
132
+ 6. Trademarks. This License does not grant permission to use the trade
133
+ names, trademarks, service marks, or product names of the Licensor,
134
+ except as required for reasonable and customary use in describing the
135
+ origin of the Work and reproducing the content of the NOTICE file.
136
+
137
+ 7. Disclaimer of Warranty. Unless required by applicable law or
138
+ agreed to in writing, Licensor provides the Work (and each
139
+ Contributor provides its Contributions) on an "AS IS" BASIS,
140
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
141
+ implied, including, without limitation, any warranties or conditions
142
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
143
+ PARTICULAR PURPOSE. You are solely responsible for determining the
144
+ appropriateness of using or reproducing the Work and assume any
145
+ risks associated with Your exercise of permissions under this License.
146
+
147
+ 8. Limitation of Liability. In no event and under no legal theory,
148
+ whether in tort (including negligence), contract, or otherwise,
149
+ unless required by applicable law (such as deliberate and grossly
150
+ negligent acts) or agreed to in writing, shall any Contributor be
151
+ liable to You for damages, including any direct, indirect, special,
152
+ incidental, or exemplary damages of any character arising as a
153
+ result of this License or out of the use or inability to use the
154
+ Work (including but not limited to damages for loss of goodwill,
155
+ work stoppage, computer failure or malfunction, or all other
156
+ commercial damages or losses), even if such Contributor has been
157
+ advised of the possibility of such damages.
158
+
159
+ 9. Accepting Warranty or Liability. While redistributing the Work or
160
+ Derivative Works thereof, You may choose to offer, and charge a fee
161
+ for, acceptance of support, warranty, indemnity, or other liability
162
+ obligations and/or rights consistent with this License. However,
163
+ in accepting such obligations, You may offer such obligations only
164
+ on Your own behalf and on Your sole responsibility, not on behalf
165
+ of any other Contributor, and only if You agree to indemnify,
166
+ defend, and hold each Contributor harmless for any liability
167
+ incurred by, or claims asserted against, such Contributor by reason
168
+ of your accepting any such warranty or additional liability.
169
+
170
+ END OF TERMS AND CONDITIONS
171
+
172
+ Copyright 2026 Evgeny Sokolov (FastJoe)
173
+
174
+ Licensed under the Apache License, Version 2.0 (the "License");
175
+ you may not use this file except in compliance with the License.
176
+ You may obtain a copy of the License at
177
+
178
+ http://www.apache.org/licenses/LICENSE-2.0
179
+
180
+ Unless required by applicable law or agreed to in writing, software
181
+ distributed under the License is distributed on an "AS IS" BASIS,
182
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
183
+ implied. See the License for the specific language governing
184
+ permissions and limitations under the License.
@@ -0,0 +1,215 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 Evgeny Sokolov (FastJoe)
3
+
4
+ require 'bigdecimal'
5
+
6
+ module ActivePostgrest
7
+ class Base
8
+ POSTGRES_TYPE_CAST = {
9
+ 'date' => :date,
10
+ 'timestamp' => :datetime,
11
+ 'timestamp with time zone' => :datetime,
12
+ 'timestamp without time zone' => :datetime,
13
+ 'time' => :time,
14
+ 'time with time zone' => :time,
15
+ 'time without time zone' => :time,
16
+ 'numeric' => :decimal,
17
+ 'decimal' => :decimal,
18
+ 'real' => :decimal,
19
+ 'double precision' => :decimal
20
+ }.freeze
21
+
22
+ def self.table_name
23
+ @table_name ||= name.demodulize.underscore.pluralize
24
+ end
25
+
26
+ class << self
27
+ attr_writer :table_name, :schema_name
28
+ end
29
+
30
+ def self.schema_name
31
+ @schema_name || (superclass.schema_name if superclass.respond_to?(:schema_name))
32
+ end
33
+
34
+ def self.primary_key
35
+ @primary_key ||= 'id'
36
+ end
37
+
38
+ def self.primary_key=(key)
39
+ @primary_key = key.to_s
40
+ end
41
+
42
+ def self.establish_connection(url: ENV.fetch('POSTGREST_URL'), jwt_token: nil)
43
+ @connection = ActivePostgrest::Client.new(url, jwt_token)
44
+ end
45
+
46
+ def self.connection
47
+ @connection ||
48
+ if superclass.respond_to?(:connection)
49
+ superclass.connection
50
+ else
51
+ ActivePostgrest::Client.new
52
+ end
53
+ end
54
+
55
+ def self.attribute(name, type)
56
+ @attribute_types ||= {}
57
+ @attribute_types[name.to_s] = type
58
+ end
59
+
60
+ def self.attribute_types
61
+ @attribute_types || {}
62
+ end
63
+
64
+ def self.schema
65
+ connection.table_schema(table_name)
66
+ end
67
+
68
+ def self.attributes
69
+ schema['properties']&.transform_values { _1['format'] } || {}
70
+ end
71
+
72
+ def self.belongs_to(name, class_name: nil, foreign_key: nil)
73
+ assoc = name.to_s
74
+ klass = class_name&.to_s || assoc.camelize
75
+ table = klass.underscore.pluralize
76
+ fk = foreign_key&.to_s
77
+ aliased = fk || (klass.underscore != assoc)
78
+ key = aliased ? assoc : table
79
+
80
+ define_method(assoc) do
81
+ val = @attributes[key]
82
+ return nil if val.nil? || (val.is_a?(Array) && val.empty?)
83
+
84
+ klass.constantize.new(val.is_a?(Array) ? val.first : val)
85
+ end
86
+
87
+ define_singleton_method(:"with_#{assoc}") do |fields: []|
88
+ if aliased
89
+ joins(table.to_sym, as: assoc.to_sym, foreign_key: fk&.to_sym, select: fields)
90
+ else
91
+ joins(table.to_sym, select: fields)
92
+ end
93
+ end
94
+ end
95
+
96
+ def self.has_one(name, class_name: nil)
97
+ assoc = name.to_s
98
+ klass = class_name&.to_s || assoc.camelize
99
+
100
+ define_method(assoc) do
101
+ val = @attributes[assoc]
102
+ return nil if val.nil? || (val.is_a?(Array) && val.empty?)
103
+
104
+ klass.constantize.new(val.is_a?(Array) ? val.first : val)
105
+ end
106
+
107
+ define_singleton_method(:"with_#{assoc}") do |fields: []|
108
+ embed(assoc.to_sym, fields: fields)
109
+ end
110
+ end
111
+
112
+ def self.has_many(name, class_name: nil)
113
+ assoc = name.to_s
114
+ klass = class_name&.to_s || assoc.singularize.camelize
115
+
116
+ define_method(assoc) do
117
+ val = @attributes[assoc]
118
+ return [] if val.nil?
119
+
120
+ Array(val).map { klass.constantize.new(_1) }
121
+ end
122
+
123
+ define_singleton_method(:"with_#{assoc}") do |fields: []|
124
+ embed(assoc.to_sym, fields: fields)
125
+ end
126
+ end
127
+
128
+ def self.scope(name, body)
129
+ define_singleton_method(name) { |*args, **kwargs| body.call(*args, **kwargs) }
130
+ end
131
+
132
+ def self.relation
133
+ rel = ActivePostgrest::Relation.new(table_name, connection, self)
134
+ schema_name ? rel.with_schema(schema_name) : rel
135
+ end
136
+
137
+ def self.all = relation
138
+ def self.none = relation.none
139
+ def self.anonymous = relation.anonymous
140
+ def self.with_token(jwt) = relation.with_token(jwt)
141
+ def self.with_schema(name) = relation.with_schema(name)
142
+ def self.where(filters = nil) = relation.where(filters)
143
+ def self.not_where(filters) = relation.not_where(filters)
144
+ def self.or_where(conditions) = relation.or_where(conditions)
145
+ def self.and_where(conditions) = relation.and_where(conditions)
146
+ def self.order(...) = relation.order(...)
147
+ def self.reorder(...) = relation.reorder(...)
148
+ def self.limit(n) = relation.limit(n)
149
+ def self.offset(n) = relation.offset(n)
150
+ def self.joins(...) = relation.joins(...)
151
+ def self.embed(...) = relation.embed(...)
152
+ def self.select(...) = relation.select(...)
153
+ def self.spread(...) = relation.spread(...)
154
+
155
+ def self.first(n = nil) = n ? relation.limit(n).to_a : relation.first
156
+ def self.last(n = nil) = n ? relation.order(primary_key, :desc).limit(n).to_a.reverse : relation.last
157
+ def self.find(id) = relation.where(id: id).first
158
+ def self.find!(id) = find(id) || raise(ActivePostgrest::RecordNotFound.new(self, id))
159
+ def self.find_by(filters) = relation.where(filters).first
160
+ def self.find_by!(filters) = find_by(filters) || raise(ActivePostgrest::RecordNotFound.new(self, filters))
161
+
162
+ def self.exists?(filters = {})
163
+ filters.empty? ? relation.any? : relation.where(filters).any?
164
+ end
165
+
166
+ def self.pluck(*cols) = relation.pluck(*cols)
167
+ def self.pick(*cols) = relation.pick(*cols)
168
+ def self.count(mode = :exact) = relation.count(mode)
169
+ def self.any? = relation.any?
170
+ def self.none? = relation.none?
171
+ def self.one? = relation.one?
172
+ def self.many? = relation.many?
173
+
174
+ def initialize(attrs = {})
175
+ types = self.class.attribute_types
176
+ @attributes = attrs.to_h.transform_keys(&:to_s).to_h do |k, v|
177
+ [k, types[k] ? cast_attribute(v, types[k]) : v]
178
+ end
179
+ end
180
+
181
+ def [](key) = @attributes[key.to_s]
182
+ attr_reader :attributes
183
+
184
+ def to_h = @attributes
185
+
186
+ def inspect
187
+ "#<#{self.class.name} #{@attributes.map { "#{_1}: #{_2.inspect}" }.join(', ')}>"
188
+ end
189
+
190
+ def method_missing(name, *)
191
+ key = name.to_s
192
+ return @attributes[key] if @attributes.key?(key)
193
+
194
+ super
195
+ end
196
+
197
+ def respond_to_missing?(name, include_private = false)
198
+ @attributes.key?(name.to_s) || super
199
+ end
200
+
201
+ private
202
+
203
+ def cast_attribute(value, type)
204
+ return nil if value.nil?
205
+
206
+ case type
207
+ when :date then Date.parse(value.to_s)
208
+ when :datetime, :time then Time.parse(value.to_s)
209
+ when :decimal then BigDecimal(value.to_s)
210
+ when :integer then Integer(value)
211
+ else value
212
+ end
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,74 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 Evgeny Sokolov (FastJoe)
3
+
4
+ module ActivePostgrest
5
+ class Client
6
+ attr_reader :base_url
7
+
8
+ def initialize(base_url = ENV.fetch('POSTGREST_URL'), jwt_token = nil)
9
+ @base_url = base_url
10
+ @auth_header = "Bearer #{jwt_token}" if jwt_token
11
+ @conn = Faraday.new(base_url, request: { params_encoder: Faraday::FlatParamsEncoder }) do |f|
12
+ f.request :json
13
+ f.response :json
14
+ end
15
+ end
16
+
17
+ def openapi
18
+ @openapi ||= @conn.get('/').body
19
+ end
20
+
21
+ def tables
22
+ openapi['paths']&.keys&.filter_map { |p| p.delete_prefix('/').then { |s| s.empty? ? nil : s } } || []
23
+ end
24
+
25
+ def table_schema(table)
26
+ openapi.dig('definitions', table) || {}
27
+ end
28
+
29
+ def explain(resource, params = {}, schema: nil)
30
+ @conn.get(resource, params) do |req|
31
+ auth_headers(req)
32
+ req.headers['Accept'] = 'application/vnd.pgrst.plan+text; for="application/json"; options=verbose'
33
+ req.headers['Accept-Profile'] = schema if schema
34
+ end.body
35
+ end
36
+
37
+ def get(resource, params = {}, count: :exact, schema: nil)
38
+ response = @conn.get(resource, params) do |req|
39
+ auth_headers(req)
40
+ req.headers['Prefer'] = "count=#{count}"
41
+ req.headers['Accept-Profile'] = schema if schema
42
+ end
43
+ raise_on_error!(response)
44
+ response
45
+ end
46
+
47
+ def anonymous
48
+ self.class.new(@base_url)
49
+ end
50
+
51
+ def with_token(jwt)
52
+ self.class.new(@base_url, jwt)
53
+ end
54
+
55
+ private
56
+
57
+ def raise_on_error!(response)
58
+ klass = case response.status
59
+ when 400 then BadRequest
60
+ when 401 then Unauthorized
61
+ when 403 then Forbidden
62
+ when 404 then ResourceNotFound
63
+ when 409 then Conflict
64
+ when 422 then UnprocessableEntity
65
+ when 500..599 then ServerError
66
+ end
67
+ raise klass, response if klass
68
+ end
69
+
70
+ def auth_headers(req)
71
+ req.headers['Authorization'] = @auth_header if @auth_header
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,32 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 Evgeny Sokolov (FastJoe)
3
+
4
+ module ActivePostgrest
5
+ class Error < StandardError
6
+ attr_reader :code, :details, :hint, :http_status
7
+
8
+ def initialize(response)
9
+ body = response.body.is_a?(Hash) ? response.body : {}
10
+ @http_status = response.status
11
+ @code = body['code']
12
+ @details = body['details']
13
+ @hint = body['hint']
14
+ super(body['message'] || "HTTP #{response.status}")
15
+ end
16
+ end
17
+
18
+ # 400
19
+ class BadRequest < Error; end
20
+ # 401
21
+ class Unauthorized < Error; end
22
+ # 403
23
+ class Forbidden < Error; end
24
+ # 404 — таблица/схема не найдена
25
+ class ResourceNotFound < Error; end
26
+ # 409 — unique violation
27
+ class Conflict < Error; end
28
+ # 422 — FK, not null, check
29
+ class UnprocessableEntity < Error; end
30
+ # 5xx
31
+ class ServerError < Error; end
32
+ end
@@ -0,0 +1,328 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 Evgeny Sokolov (FastJoe)
3
+
4
+ module ActivePostgrest
5
+ class RecordNotFound < StandardError
6
+ def initialize(model, id)
7
+ super("#{model.name} not found: #{id.inspect}")
8
+ end
9
+ end
10
+
11
+ class CountNotAvailable < StandardError; end
12
+
13
+ class Relation
14
+ include Enumerable
15
+
16
+ class WhereChain
17
+ def initialize(relation)
18
+ @relation = relation
19
+ end
20
+
21
+ def not(filters)
22
+ @relation.not_where(filters)
23
+ end
24
+ end
25
+
26
+ def initialize(table, client, model_class)
27
+ @table = table
28
+ @client = client
29
+ @model_class = model_class
30
+ @selects = []
31
+ @joins = []
32
+ @filters = {}
33
+ @or_conditions = []
34
+ @and_conditions = []
35
+ @limit_val = nil
36
+ @offset_val = nil
37
+ @order_val = nil
38
+ @null = false
39
+ @schema = nil
40
+ end
41
+
42
+ def select(*cols)
43
+ clone_with { @selects.concat(cols.map(&:to_s)) }
44
+ end
45
+
46
+ def spread(*tables)
47
+ clone_with { @selects.concat(tables.map { "...#{_1}" }) }
48
+ end
49
+
50
+ # joins(:companies) → INNER JOIN (excludes rows with no match)
51
+ # joins(:users, as: :mother, foreign_key: :mother_id) → aliased FK join
52
+ def joins(table, as: nil, foreign_key: nil, select: [], where: {})
53
+ embed = build_embed(table.to_s, as&.to_s, foreign_key&.to_s, inner: true)
54
+ add_embed(embed, table.to_s, select.map(&:to_s), where)
55
+ end
56
+
57
+ # left_joins(:companies) → LEFT JOIN (includes rows even with no match)
58
+ def left_joins(table, as: nil, foreign_key: nil, select: [], where: {})
59
+ embed = build_embed(table.to_s, as&.to_s, foreign_key&.to_s, inner: false)
60
+ add_embed(embed, table.to_s, select.map(&:to_s), where)
61
+ end
62
+
63
+ # embed(:mother, fields: [:id, :first_name]) — computed relationship
64
+ def embed(name, fields: [])
65
+ add_embed(name.to_s, name.to_s, fields.map(&:to_s), {})
66
+ end
67
+
68
+ # where(name: "John") → name=eq.John
69
+ # where(name: nil) → name=is.null
70
+ # where(active: true) → active=is.true
71
+ # where(id: [1, 2, 3]) → id=in.(1,2,3)
72
+ # where(age: 18..30) → age=gte.18&age=lte.30
73
+ # where(age: { gt: 18, lt: 65 }) → age=gt.18&age=lt.65
74
+ # where(companies: { name: "Acme" }) → companies.name=eq.Acme (AR-style joins filter)
75
+ # where.not(name: "John") → name=not.eq.John
76
+ def where(filters = nil)
77
+ return WhereChain.new(self) if filters.nil?
78
+
79
+ clone_with { encode_filters!(filters) }
80
+ end
81
+
82
+ def not_where(filters)
83
+ clone_with { encode_filters!(filters, negate: true) }
84
+ end
85
+
86
+ # or_where([{ age: { lt: 18 } }, { status: "active" }]) → or=(age.lt.18,status.eq.active)
87
+ def or_where(conditions)
88
+ parts = Array(conditions).flat_map { |f| condition_parts(f) }
89
+ clone_with { @or_conditions.concat(parts) }
90
+ end
91
+
92
+ # and_where([{ age: { gt: 18 } }, { status: "active" }]) → and=(age.gt.18,status.eq.active)
93
+ def and_where(conditions)
94
+ parts = Array(conditions).flat_map { |f| condition_parts(f) }
95
+ clone_with { @and_conditions.concat(parts) }
96
+ end
97
+
98
+ def limit(n) = clone_with { @limit_val = n }
99
+ def offset(n) = clone_with { @offset_val = n }
100
+
101
+ def order(col, dir = :asc, nulls: nil)
102
+ clone_with { @order_val = build_order(col, dir, nulls) }
103
+ end
104
+
105
+ def reorder(col, dir = :asc, nulls: nil)
106
+ clone_with { @order_val = build_order(col, dir, nulls) }
107
+ end
108
+
109
+ def none = clone_with { @null = true }
110
+ def anonymous = clone_with { @client = @client.anonymous }
111
+ def with_token(jwt) = clone_with { @client = @client.with_token(jwt) }
112
+ def with_schema(name) = clone_with { @schema = name }
113
+
114
+ def each(&)
115
+ to_a.each(&)
116
+ end
117
+
118
+ def to_a
119
+ return [] if @null
120
+
121
+ Array(@client.get(@table, build_params, schema: @schema).body).map { |attrs| @model_class.new(attrs) }
122
+ end
123
+
124
+ def first
125
+ limit(1).to_a.first
126
+ end
127
+
128
+ def last(n = nil)
129
+ pk = @model_class.primary_key
130
+ return order(pk, :desc).limit(n).to_a.reverse if n
131
+
132
+ order(pk, :desc).limit(1).to_a.first
133
+ end
134
+
135
+ def count(mode = :exact)
136
+ return 0 if @null
137
+
138
+ response = @client.get(@table, build_params.merge(limit: 0), count: mode, schema: @schema)
139
+ raw = response.headers['content-range']&.split('/')&.last
140
+ total = raw&.delete_prefix('~')
141
+ if total.nil? || total == '*'
142
+ raise CountNotAvailable, "count=#{mode} not available (Content-Range: #{raw.inspect})"
143
+ end
144
+
145
+ total.to_i
146
+ end
147
+
148
+ def any?(&block) = block ? super : count.positive?
149
+ def none?(&block) = block ? super : count.zero?
150
+ def one?(&block) = block ? super : count == 1
151
+ def many? = count > 1
152
+ def exists? = any?
153
+
154
+ def average(col) = aggregate_value("#{col}.avg()", 'avg')
155
+ def sum(col) = aggregate_value("#{col}.sum()", 'sum')
156
+ def minimum(col) = aggregate_value("#{col}.min()", 'min')
157
+ def maximum(col) = aggregate_value("#{col}.max()", 'max')
158
+
159
+ def pluck(*cols)
160
+ return [] if @null
161
+
162
+ select(*cols).to_a.map do |record|
163
+ cols.length == 1 ? record[cols.first] : cols.map { record[_1] }
164
+ end
165
+ end
166
+
167
+ def pick(*cols)
168
+ pluck(*cols).first
169
+ end
170
+
171
+ # Returns a human-readable SQL-like representation of the query reconstructed
172
+ # from the relation's internal state — no database call is made.
173
+ #
174
+ # Limitations vs actual SQL:
175
+ # - Embedded resources use PostgREST notation: companies(*) instead of
176
+ # LEFT JOIN companies ON companies.id = users.company_id.
177
+ # - Parameters are shown as literal values, not PostgreSQL placeholders ($1, $2).
178
+ # - The actual query PostgREST sends is a CTE (WITH pgrst_source AS (...))
179
+ # and may differ in structure. Use #explain to see the real execution plan.
180
+ def to_sql
181
+ clauses = ["SELECT #{sql_select}", "FROM #{@table}"]
182
+
183
+ wheres = sql_where_clauses
184
+ clauses << "WHERE #{wheres.join("\n AND ")}" if wheres.any?
185
+
186
+ clauses << "ORDER BY #{sql_order}" if @order_val
187
+ clauses << "LIMIT #{@limit_val}" if @limit_val
188
+ clauses << "OFFSET #{@offset_val}" if @offset_val
189
+
190
+ clauses.join("\n")
191
+ end
192
+
193
+ def explain
194
+ @client.explain(@table, build_params, schema: @schema)
195
+ end
196
+
197
+ def to_url
198
+ params = build_params
199
+ base = "#{@client.base_url}/#{@table}"
200
+ return base if params.empty?
201
+
202
+ query = params.flat_map { |k, v| Array(v).map { "#{k}=#{_1}" } }.join('&')
203
+ "#{base}?#{query}"
204
+ end
205
+
206
+ def method_missing(name, *, **)
207
+ return super unless @model_class.respond_to?(name)
208
+
209
+ scope = @model_class.public_send(name, *, **)
210
+ return super unless scope.is_a?(ActivePostgrest::Relation)
211
+
212
+ merge(scope)
213
+ end
214
+
215
+ def respond_to_missing?(name, include_private = false)
216
+ @model_class.respond_to?(name) || super
217
+ end
218
+
219
+ def inspect
220
+ to_a.inspect
221
+ end
222
+
223
+ include SqlBuilder
224
+
225
+ private
226
+
227
+ def merge(other)
228
+ clone_with do
229
+ @selects.concat(other.instance_variable_get(:@selects))
230
+ @joins.concat(other.instance_variable_get(:@joins))
231
+ other.instance_variable_get(:@filters).each { |k, v| merge_filter!(k, v) }
232
+ @or_conditions.concat(other.instance_variable_get(:@or_conditions))
233
+ @and_conditions.concat(other.instance_variable_get(:@and_conditions))
234
+ @limit_val = other.instance_variable_get(:@limit_val) if other.instance_variable_get(:@limit_val)
235
+ @offset_val = other.instance_variable_get(:@offset_val) if other.instance_variable_get(:@offset_val)
236
+ @order_val = other.instance_variable_get(:@order_val) if other.instance_variable_get(:@order_val)
237
+ @null = true if other.instance_variable_get(:@null)
238
+ end
239
+ end
240
+
241
+ def add_embed(embed_str, table, select, where)
242
+ clone_with { @joins << { embed: embed_str, select: select, where: where, table: table } }
243
+ end
244
+
245
+ def build_embed(table, as_name, foreign_key, inner: false)
246
+ embed = as_name ? "#{as_name}:#{table}" : table
247
+ embed += "!#{foreign_key}" if foreign_key
248
+ embed += '!inner' if inner
249
+ embed
250
+ end
251
+
252
+ def encode_filters!(filters, negate: false)
253
+ prefix = negate ? 'not.' : ''
254
+ filters.each do |col, val|
255
+ if table_condition?(val)
256
+ val.each do |sub_col, sub_val|
257
+ encoded = encode_value(sub_val, prefix: prefix)
258
+ merge_filter!("#{col}.#{sub_col}", encoded) unless encoded.nil?
259
+ end
260
+ else
261
+ encoded = encode_value(val, prefix: prefix)
262
+ merge_filter!(col.to_s, encoded) unless encoded.nil?
263
+ end
264
+ end
265
+ end
266
+
267
+ def aggregate_value(expr, key)
268
+ return nil if @null
269
+
270
+ clone_with do
271
+ @selects = [expr]
272
+ @joins = []
273
+ @limit_val = nil
274
+ @offset_val = nil
275
+ @order_val = nil
276
+ end.to_a.first&.[](key)
277
+ end
278
+
279
+ def condition_parts(filters)
280
+ filters.flat_map do |col, val|
281
+ Array(encode_value(val)).map { "#{col}.#{_1}" }
282
+ end
283
+ end
284
+
285
+ def build_params
286
+ params = {}
287
+ params[:select] = build_select if @selects.any? || @joins.any?
288
+ params[:order] = @order_val if @order_val
289
+ params[:limit] = @limit_val if @limit_val
290
+ params[:offset] = @offset_val if @offset_val
291
+ params[:or] = "(#{@or_conditions.join(',')})" if @or_conditions.any?
292
+ params[:and] = "(#{@and_conditions.join(',')})" if @and_conditions.any?
293
+ params.merge!(@filters)
294
+
295
+ @joins.each do |j|
296
+ j[:where].each do |col, val|
297
+ key = "#{j[:table]}.#{col}"
298
+ encoded = encode_value(val)
299
+ existing = params[key]
300
+ params[key] = existing ? [*Array(existing), *Array(encoded)] : encoded
301
+ end
302
+ end
303
+
304
+ params
305
+ end
306
+
307
+ def build_select
308
+ parts = @selects.empty? ? ['*'] : @selects.dup
309
+ @joins.each do |j|
310
+ inner = j[:select].any? ? j[:select].join(',') : '*'
311
+ parts << "#{j[:embed]}(#{inner})"
312
+ end
313
+ parts.join(',')
314
+ end
315
+
316
+ def clone_with(&block)
317
+ dup.tap do |copy|
318
+ copy.instance_variable_set(:@selects, @selects.dup)
319
+ copy.instance_variable_set(:@joins, @joins.dup)
320
+ copy.instance_variable_set(:@filters, @filters.dup)
321
+ copy.instance_variable_set(:@or_conditions, @or_conditions.dup)
322
+ copy.instance_variable_set(:@and_conditions, @and_conditions.dup)
323
+ copy.instance_variable_set(:@null, @null)
324
+ copy.instance_eval(&block)
325
+ end
326
+ end
327
+ end
328
+ end
@@ -0,0 +1,124 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 Evgeny Sokolov (FastJoe)
3
+
4
+ module ActivePostgrest
5
+ module SqlBuilder
6
+ FILTER_OPS = {
7
+ 'eq' => '=', 'neq' => '!=', 'gt' => '>', 'gte' => '>=',
8
+ 'lt' => '<', 'lte' => '<=', 'like' => 'LIKE', 'ilike' => 'ILIKE',
9
+ 'fts' => '@@', 'cs' => '@>', 'cd' => '<@'
10
+ }.freeze
11
+
12
+ NEGATED_OPS = {
13
+ 'eq' => '!=', 'neq' => '=', 'gt' => '<=', 'gte' => '<',
14
+ 'lt' => '>=', 'lte' => '>', 'like' => 'NOT LIKE', 'ilike' => 'NOT ILIKE'
15
+ }.freeze
16
+
17
+ KNOWN_OP_KEYS = (FILTER_OPS.keys + %w[not in is]).freeze
18
+
19
+ private
20
+
21
+ def table_condition?(val)
22
+ val.is_a?(Hash) && val.keys.none? { KNOWN_OP_KEYS.include?(_1.to_s) }
23
+ end
24
+
25
+ def encode_value(val, prefix: '')
26
+ case val
27
+ when nil then "#{prefix}is.null"
28
+ when true then "#{prefix}is.true"
29
+ when false then "#{prefix}is.false"
30
+ when Array then "#{prefix}in.(#{val.map { encode_in_value(_1) }.join(',')})"
31
+ when Range
32
+ parts = []
33
+ parts << "#{prefix}gte.#{val.begin}" unless val.begin.nil?
34
+ parts << (val.exclude_end? ? "#{prefix}lt.#{val.end}" : "#{prefix}lte.#{val.end}") unless val.end.nil?
35
+ return nil if parts.empty?
36
+
37
+ parts.one? ? parts.first : parts
38
+ when Hash
39
+ parts = val.map { |op, v| "#{prefix}#{op}.#{v}" }
40
+ parts.one? ? parts.first : parts
41
+ else
42
+ "#{prefix}eq.#{val}"
43
+ end
44
+ end
45
+
46
+ def encode_in_value(v)
47
+ str = v.to_s
48
+ str.include?(',') ? %("#{str}") : str
49
+ end
50
+
51
+ def sql_select
52
+ parts = @selects.empty? ? ['*'] : @selects.dup
53
+ @joins.each do |j|
54
+ cols = j[:select].any? ? j[:select].join(', ') : '*'
55
+ parts << "#{j[:embed]}(#{cols})"
56
+ end
57
+ parts.join(', ')
58
+ end
59
+
60
+ def sql_where_clauses
61
+ clauses = @filters.flat_map { |col, encoded| Array(encoded).map { decode_filter(col, _1) } }
62
+ @joins.each do |j|
63
+ j[:where].each do |col, val|
64
+ Array(encode_value(val)).each { |encoded| clauses << decode_filter("#{j[:table]}.#{col}", encoded) }
65
+ end
66
+ end
67
+ clauses << "(#{@or_conditions.map { decode_condition(_1) }.join(' OR ')})" if @or_conditions.any?
68
+ clauses << "(#{@and_conditions.map { decode_condition(_1) }.join(' AND ')})" if @and_conditions.any?
69
+ clauses
70
+ end
71
+
72
+ def sql_order
73
+ col, dir, nulls = @order_val.split('.')
74
+ nulls_sql = { 'nullslast' => 'NULLS LAST', 'nullsfirst' => 'NULLS FIRST' }[nulls]
75
+ [col, dir&.upcase || 'ASC', nulls_sql].compact.join(' ')
76
+ end
77
+
78
+ def build_order(col, dir, nulls)
79
+ val = "#{col}.#{dir}"
80
+ val += ".nulls#{nulls}" if nulls
81
+ val
82
+ end
83
+
84
+ def merge_filter!(col, encoded)
85
+ existing = @filters[col]
86
+ @filters[col] = existing ? [*Array(existing), *Array(encoded)] : encoded
87
+ end
88
+
89
+ def decode_filter(col, encoded)
90
+ negated = encoded.start_with?('not.')
91
+ rest = negated ? encoded[4..] : encoded
92
+ op, val = rest.split('.', 2)
93
+
94
+ case op
95
+ when 'is' then "#{col} IS#{' NOT' if negated} #{val.upcase}"
96
+ when 'in' then decode_in(col, val, negated)
97
+ else
98
+ sql_op = negated ? (NEGATED_OPS[op] || "NOT #{FILTER_OPS[op] || op}") : (FILTER_OPS[op] || op)
99
+ "#{col} #{sql_op} #{sql_quote(val)}"
100
+ end
101
+ end
102
+
103
+ def decode_in(col, val, negated)
104
+ vals = val.delete_prefix('(').delete_suffix(')').split(',').map { |v| sql_quote(v.strip.delete('"')) }
105
+ "#{col} #{'NOT ' if negated}IN (#{vals.join(', ')})"
106
+ end
107
+
108
+ def decode_condition(cond)
109
+ parts = cond.split('.')
110
+ op_idx = parts.index { KNOWN_OP_KEYS.include?(_1) }
111
+ return cond unless op_idx
112
+
113
+ col = parts[0...op_idx].join('.')
114
+ encoded = parts[op_idx..].join('.')
115
+ decode_filter(col, encoded)
116
+ end
117
+
118
+ def sql_quote(val)
119
+ return val if val&.match?(/\A-?\d+(\.\d+)?\z/)
120
+
121
+ "'#{val.to_s.gsub("'", "''")}'"
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,6 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 Evgeny Sokolov (FastJoe)
3
+
4
+ module ActivePostgrest
5
+ VERSION = '0.1.0'.freeze
6
+ end
@@ -0,0 +1,14 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 Evgeny Sokolov (FastJoe)
3
+
4
+ require 'active_support'
5
+ require 'active_support/core_ext/string'
6
+ require 'faraday'
7
+ require 'bigdecimal'
8
+
9
+ require 'active_postgrest/version'
10
+ require 'active_postgrest/errors'
11
+ require 'active_postgrest/client'
12
+ require 'active_postgrest/sql_builder'
13
+ require 'active_postgrest/relation'
14
+ require 'active_postgrest/base'
@@ -0,0 +1,52 @@
1
+ require 'rails/generators'
2
+
3
+ module ActivePostgrest
4
+ module Generators
5
+ class ModelGenerator < Rails::Generators::Base
6
+ argument :name, type: :string, desc: 'Table name, e.g. users'
7
+
8
+ def generate_attributes_concern
9
+ klass = singular.camelize
10
+ defn = ActivePostgrest::Base.connection.table_schema(table)
11
+ props = defn['properties'] || {}
12
+
13
+ attrs = props.filter_map do |col, info|
14
+ type = ActivePostgrest::Base::POSTGRES_TYPE_CAST[info['format']]
15
+ " attribute :#{col}, :#{type}" if type
16
+ end
17
+
18
+ content = <<~RUBY
19
+ # Auto-generated by `rails g active_postgrest:model #{name}`. Do not edit manually.
20
+ module #{klass}Attributes
21
+ extend ActiveSupport::Concern
22
+ included do
23
+ #{attrs.any? ? attrs.join("\n") : ' # no type casts needed'}
24
+ end
25
+ end
26
+ RUBY
27
+
28
+ create_file("app/models/concerns/#{singular}_attributes.rb", content, force: true)
29
+ end
30
+
31
+ def create_model_file
32
+ path = "app/models/#{singular}.rb"
33
+
34
+ if File.exist?(File.join(destination_root, path))
35
+ say_status :exist, path, :blue
36
+ say " → add `include #{singular.camelize}Attributes` to #{path} if not present", :cyan
37
+ else
38
+ create_file(path, <<~RUBY)
39
+ class #{singular.camelize} < ActivePostgrest::Base
40
+ include #{singular.camelize}Attributes
41
+ end
42
+ RUBY
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def table = name.tableize
49
+ def singular = table.singularize
50
+ end
51
+ end
52
+ end
metadata ADDED
@@ -0,0 +1,124 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: active_postgrest
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Evgeny Sokolov
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-06-17 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: faraday
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '2.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '2.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: faraday-net_http
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '2.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '2.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: activesupport
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '7.0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '7.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rubocop
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '1.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '1.0'
83
+ description: Query PostgREST APIs using a familiar ActiveRecord-like interface
84
+ email:
85
+ - evgeny.sokolov@gmail.com
86
+ executables: []
87
+ extensions: []
88
+ extra_rdoc_files: []
89
+ files:
90
+ - LICENSE
91
+ - lib/active_postgrest.rb
92
+ - lib/active_postgrest/base.rb
93
+ - lib/active_postgrest/client.rb
94
+ - lib/active_postgrest/errors.rb
95
+ - lib/active_postgrest/relation.rb
96
+ - lib/active_postgrest/sql_builder.rb
97
+ - lib/active_postgrest/version.rb
98
+ - lib/generators/active_postgrest/model_generator.rb
99
+ homepage: https://github.com/FastJoe/active-postgrest
100
+ licenses:
101
+ - Apache-2.0
102
+ metadata:
103
+ source_code_uri: https://github.com/FastJoe/active-postgrest
104
+ changelog_uri: https://github.com/FastJoe/active-postgrest/blob/main/CHANGELOG.md
105
+ post_install_message:
106
+ rdoc_options: []
107
+ require_paths:
108
+ - lib
109
+ required_ruby_version: !ruby/object:Gem::Requirement
110
+ requirements:
111
+ - - ">="
112
+ - !ruby/object:Gem::Version
113
+ version: '3.0'
114
+ required_rubygems_version: !ruby/object:Gem::Requirement
115
+ requirements:
116
+ - - ">="
117
+ - !ruby/object:Gem::Version
118
+ version: '0'
119
+ requirements: []
120
+ rubygems_version: 3.0.3.1
121
+ signing_key:
122
+ specification_version: 4
123
+ summary: ActiveRecord-style Ruby client for PostgREST
124
+ test_files: []