portolan 0.1.0.pre.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9ddd564427e8a47bf2013520a1f2adb0de2970481c6c83f55f20ea4cd751c818
4
+ data.tar.gz: ffe366aa7c3f03197e814f68648d1463b54c57f155af0ffa0782495e58390c85
5
+ SHA512:
6
+ metadata.gz: 1d696d9332f633dec4a9eb3231d538288c6aa17e387277cb4f75234b0d8f69f8a397e2dddd4019e3f0adb9e92616f5a4bcb919c3609d4cc24972f6176d022014
7
+ data.tar.gz: 96e1499eb19e2f94e7d16811553fa5fa29dc3d686c318a63c318b2c14015970846569794aa5e1d14b443a9952c361392b0167d4a0790b8d88985252a918a3a1d
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 David Busse
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,202 @@
1
+ # Portolan
2
+
3
+ Let LLMs and reporting tools query your Rails data safely: only the datasets,
4
+ dimensions, and measures you declare.
5
+
6
+ You declare each dataset in Ruby: the rows it covers, the dimensions it can be
7
+ grouped and filtered by, the measures it can count or sum, and worked examples.
8
+ A dashboard, a scheduled report, or an LLM sends a structured request naming a
9
+ dataset and what to see from it. Portolan checks the request against your
10
+ declaration, builds the SQL itself, and returns only what the declaration
11
+ allows. Nothing outside your code ever writes SQL or reaches a column you
12
+ didn't declare.
13
+
14
+ Giving an LLM database access usually means trusting every query it writes.
15
+ Portolan turns that around: it can only ask questions you have already made
16
+ answerable.
17
+
18
+ Portolan is in early development. Today it answers with aggregates: counts,
19
+ sums, and averages, grouped by the dimensions you declared. It requires
20
+ PostgreSQL.
21
+
22
+ ## Installation
23
+
24
+ ```ruby
25
+ gem 'portolan', '~> 0.1.0.pre'
26
+ ```
27
+
28
+ Portolan needs Ruby 3.3 or later, ActiveRecord 7.1 or later, and PostgreSQL.
29
+
30
+ ## Declaring a dataset
31
+
32
+ A dataset is a class. It says which rows it covers, how they may be grouped and
33
+ filtered, and what may be counted or summed.
34
+
35
+ ```ruby
36
+ class Voyages < Portolan::Dataset
37
+ description 'One row per voyage.'
38
+
39
+ model Voyage
40
+ relation { |fleet| Voyage.where(fleet:) }
41
+
42
+ dimension :harbor, 'the harbor the voyage set out from'
43
+ dimension :crew, 'how many sailors were aboard', type: :integer
44
+ dimension :departed_at, 'when the ship set sail', type: :datetime
45
+ dimension :flag, 'the flag the ship sailed under', joins: :ship, expression: 'ships.flag'
46
+ dimension :fate, 'how the voyage ended', buckets: {
47
+ arrived: { where: -> { Voyage.where.not(arrived_at: nil) }, description: 'reached its destination' },
48
+ lost: -> { Voyage.where(lost: true) },
49
+ }
50
+
51
+ measure :voyages, 'number of voyages'
52
+ measure :ships, 'number of ships that sailed', count_distinct: :ship_id
53
+ measure :leagues, 'distance sailed, in leagues', sum: :leagues
54
+
55
+ example 'How many voyages set out each month in 1325, and how did they end?',
56
+ { measure: 'voyages', group_by: %w[departed_at fate], period: 'month',
57
+ filters: [{ dimension: 'departed_at', op: 'between', values: %w[1325-01-01 1325-12-31] }] }
58
+ end
59
+ ```
60
+
61
+ Register the datasets one audience may query:
62
+
63
+ ```ruby
64
+ FLEET_DATASETS = Portolan::Registry.new(voyages: Voyages)
65
+ ```
66
+
67
+ A registry checks every dataset when it is built, without touching the
68
+ database, so most mistakes in a declaration fail at boot, not in front of a
69
+ caller: a join through an association that doesn't exist, is a `has_many`, or
70
+ is polymorphic, and an example that isn't a valid request. Columns need the
71
+ database, so they are checked on the first query; add a spec that calls
72
+ `FLEET_DATASETS.check!` to catch a misspelled column before you deploy. Without
73
+ a `relation` block, a dataset covers every row of its model.
74
+
75
+ ## Asking a question
76
+
77
+ ```ruby
78
+ result = FLEET_DATASETS.aggregate(
79
+ { dataset: 'voyages', measure: 'voyages', group_by: %w[fate], filters: [{ dimension: 'crew', op: 'gt', value: 20 }] },
80
+ context: current_user.fleet
81
+ )
82
+
83
+ result.success? # => true
84
+ result.rows # => [{ "fate" => "arrived", "voyages" => 12 }, { "fate" => "lost", "voyages" => 2 }]
85
+ result.to_h # => the request, rows, notes, and definitions, ready for JSON
86
+ ```
87
+
88
+ A request may come from Ruby with symbols, from JSON, or from Rails params. A
89
+ request with mistakes runs no SQL and returns every error at once, each saying
90
+ what is allowed:
91
+
92
+ ```ruby
93
+ result.errors
94
+ # => ["unknown measure `ships_lost`; allowed: `voyages`, `ships`, `leagues`",
95
+ # "filter 1: `crew`: `twenty` is not an integer"]
96
+ ```
97
+
98
+ To let an LLM ask questions, give it three tools: one that returns
99
+ `registry.list`, one that returns `registry.describe(name)`, and one that
100
+ returns `registry.aggregate(arguments, context:).to_h`. The descriptions,
101
+ examples, and errors are written for a model to read and correct itself from.
102
+
103
+ ## What keeps it safe
104
+
105
+ **The context comes from your authentication, never from the request.** The
106
+ relation block receives whatever you pass as `context:`, such as the signed-in
107
+ user or account, and uses it to scope rows. A request cannot name a context:
108
+ a `context` key is rejected as unknown. Never pass anything from the request as
109
+ the context.
110
+
111
+ **One row per something.** Each dataset has a grain: one row per voyage, per
112
+ order, per spool. Dimensions reach up from that row through `belongs_to`
113
+ associations, never down through `has_many`, because joining a `has_many`
114
+ repeats rows and inflates counts and sums; Portolan refuses such a join. To ask
115
+ across a `has_many`, either declare a dataset at the finer grain, or use a
116
+ bucket whose condition is a subquery:
117
+
118
+ ```ruby
119
+ dimension :cargo, 'what the voyage carried', buckets: {
120
+ spices: -> { Voyage.where(id: Hold.where(kind: 'spice').select(:voyage_id)) },
121
+ }
122
+ ```
123
+
124
+ **Every query runs read-only, briefly, and in UTC.** Portolan runs each query in
125
+ its own transaction with writes blocked, a 5 second timeout on each SQL
126
+ statement, and the UTC time zone, then rolls it back. When Portolan opens the
127
+ transaction itself, PostgreSQL enforces read-only too; inside a transaction of
128
+ your own, Rails blocks writes. It only changes transaction-local settings, so
129
+ nothing leaks into your app's queries, including behind a pooler such as
130
+ PgBouncer in transaction mode. A query that times out, or a value the database
131
+ cannot use, comes back as an error.
132
+
133
+ **Values are always bound parameters.** Filter values never become SQL. A
134
+ filter on a column behaves like a Rails `where` condition on it, including
135
+ rounding a decimal to the column's scale. The only SQL in a query is what you
136
+ wrote in `expression:` and in bucket conditions.
137
+
138
+ ## Reference
139
+
140
+ **Dataset declarations:** `description` and `model` are required. `relation`
141
+ is optional and receives the context; without it, a dataset covers
142
+ `model.all`. A dataset declares at least one `measure`, and any number of
143
+ `dimension`s and `example`s.
144
+
145
+ **Dimension types**
146
+
147
+ | Type | Operators | Periods |
148
+ |---|---|---|
149
+ | `:string` (default) | `eq`, `in` | |
150
+ | `:integer` | `eq`, `in`, `gt`, `lt`, `between` | |
151
+ | `:decimal` | `eq`, `in`, `gt`, `lt`, `between` | |
152
+ | `:boolean` | `eq` | |
153
+ | `:date` | `gt`, `lt`, `between` | `day`, `week`, `month`, `quarter`, `year` |
154
+ | `:datetime` | `gt`, `lt`, `between` | `hour`, `day`, `week`, `month`, `quarter`, `year` |
155
+
156
+ **Dimension options:** `type:`, `values:` (a fixed list for a string
157
+ dimension), `joins:` (a `belongs_to` association path, joined with a left
158
+ join, which needs an `expression:` naming the joined column), `expression:`
159
+ (SQL to read instead of a column), and `buckets:` (named
160
+ conditions, each a lambda returning a relation, with an optional description).
161
+
162
+ **Measures:** a plain `measure` counts rows; `count_distinct:`, `sum:`, and
163
+ `average:` take a column on the dataset's table.
164
+
165
+ **What `aggregate` accepts:** `dataset` and `measure` are required.
166
+ `group_by` takes up to three dimensions, at most one of them a date or
167
+ datetime, which then needs a `period`. `filters` is a list of
168
+ `{ dimension:, op:, value: }` (or `values:` for `in` and `between`); dates are
169
+ `YYYY-MM-DD`, and a day given for a datetime covers the whole day in UTC.
170
+ `limit` is 1 to 50 rows, and defaults to 50.
171
+
172
+ **Periods** start on Monday for weeks, are in UTC, and are labelled by their
173
+ first day (`2026-11-01`), or by the hour for `hour` (`2026-11-03T14:00Z`).
174
+ Periods with no rows are left out. A missing value comes back as `null` (`nil`
175
+ in Ruby).
176
+
177
+ ## Current limitations
178
+
179
+ - PostgreSQL only.
180
+ - A row can fall into more than one bucket if their conditions overlap.
181
+ - Buckets need a model with a single-column primary key.
182
+ - When a query joins the same table twice, Rails aliases the second join; an
183
+ `expression:` must name the table as Rails aliases it.
184
+ - Dimensions cannot join through a `has_many` or a polymorphic association; see
185
+ the grain rule above.
186
+ - The timeout applies to each SQL statement, not to the whole request. Grouping
187
+ by buckets runs one statement per bucket combination.
188
+ - Your `relation` block runs before Portolan's guard is in place, so it should
189
+ build a relation, not run queries itself.
190
+ - The timeout (5 seconds), row limit (50), group limit (10,000), and number of
191
+ bucket combinations (50) are fixed for now.
192
+
193
+ ## Why the name?
194
+
195
+ A portolan is a medieval sailing chart of known coasts, harbors, and safe
196
+ routes. Portolan does the same for your data: you chart the datasets worth
197
+ visiting, and anything that queries them can go anywhere on the chart and
198
+ nowhere off it.
199
+
200
+ ## License
201
+
202
+ MIT. See [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Portolan
4
+ class Dataset
5
+ Example = Data.define(:question, :query)
6
+
7
+ class << self
8
+ def description(text = nil)
9
+ text.nil? ? @description : @description = text
10
+ end
11
+
12
+ def model(model = nil)
13
+ model.nil? ? @model : @model = model
14
+ end
15
+
16
+ def relation(&block)
17
+ @relation = block
18
+ end
19
+
20
+ def relation_for(context)
21
+ check_columns!
22
+ relation = @relation ? @relation.call(context) : model.all
23
+ return relation if relation.is_a?(ActiveRecord::Relation) && relation.klass <= model
24
+
25
+ invalid!("relation must return a relation of `#{model.name}`")
26
+ end
27
+
28
+ def dimension(name, description, **options)
29
+ add(dimension_list, Dimension.new(name, description, **options), 'dimension')
30
+ end
31
+
32
+ def measure(name, description, **options)
33
+ add(measure_list, Measure.new(name, description, **options), 'measure')
34
+ end
35
+
36
+ def example(question, query)
37
+ @examples = nil
38
+ example_list << Example.new(question:, query: query.freeze)
39
+ end
40
+
41
+ def dimensions = @dimensions ||= dimension_list.dup.freeze
42
+ def measures = @measures ||= measure_list.dup.freeze
43
+ def examples = @examples ||= example_list.dup.freeze
44
+
45
+ def describe
46
+ {
47
+ description:,
48
+ dimensions: dimension_list.values.map(&:describe),
49
+ measures: measure_list.values.map(&:describe),
50
+ examples: example_list.map(&:to_h),
51
+ }
52
+ end
53
+
54
+ def check!
55
+ invalid!('needs a description') unless description.is_a?(String) && description.present?
56
+ invalid!('needs a model, an ActiveRecord class') unless model.is_a?(Class) && model < ActiveRecord::Base
57
+ invalid!('needs at least one measure') if measure_list.empty?
58
+ dimension_list.each_value { |dimension| check_join!(dimension, model, dimension.joins) if dimension.joins }
59
+ end
60
+
61
+ def check_columns!
62
+ return if @columns_checked
63
+
64
+ measure_list.each_value { |measure| check_measure_column!(measure) }
65
+ dimension_list.each_value { |dimension| check_dimension_column!(dimension) }
66
+ @columns_checked = true
67
+ end
68
+
69
+ private
70
+
71
+ def dimension_list = @dimension_list ||= {}
72
+ def measure_list = @measure_list ||= {}
73
+ def example_list = @example_list ||= []
74
+
75
+ def add(list, item, kind)
76
+ invalid!("#{kind} `#{item.name}` is declared twice") if list.key?(item.name)
77
+ invalid!("`#{item.name}` names both a dimension and a measure") if declared_names.include?(item.name)
78
+ @dimensions = @measures = nil
79
+ list[item.name] = item
80
+ end
81
+
82
+ def declared_names = dimension_list.keys + measure_list.keys
83
+
84
+ def check_measure_column!(measure)
85
+ return if measure.column.nil? || model.has_attribute?(measure.column)
86
+
87
+ invalid!("measure `#{measure.name}`: `#{measure.column}` is not a column of `#{model.name}`")
88
+ end
89
+
90
+ def check_dimension_column!(dimension)
91
+ return check_bucket_key!(dimension) if dimension.buckets?
92
+ return if dimension.expression || model.has_attribute?(dimension.name)
93
+
94
+ invalid!("dimension `#{dimension.name}`: `#{dimension.name}` is not a column of `#{model.name}`; " \
95
+ 'add an `expression` to read it another way')
96
+ end
97
+
98
+ def check_join!(dimension, from, path)
99
+ case path
100
+ when ::Hash then path.each { |name, rest| check_join!(dimension, check_join!(dimension, from, name), rest) }
101
+ when ::Array then path.each { |name| check_join!(dimension, from, name) }
102
+ else joined_model(dimension, from, path)
103
+ end
104
+ end
105
+
106
+ def joined_model(dimension, from, name)
107
+ reflection = from.reflect_on_association(name)
108
+ invalid!("dimension `#{dimension.name}`: `#{from.name}` has no association `#{name}`") unless reflection
109
+ if reflection.polymorphic?
110
+ invalid!("dimension `#{dimension.name}`: `#{name}` is polymorphic, and Rails cannot join it")
111
+ end
112
+ if reflection.collection?
113
+ invalid!("dimension `#{dimension.name}`: `#{name}` is a has_many, and joining it would count rows " \
114
+ 'more than once; see the grain rule')
115
+ end
116
+ reflection.klass
117
+ end
118
+
119
+ def check_bucket_key!(dimension)
120
+ return if model.primary_key.is_a?(::String)
121
+
122
+ invalid!("dimension `#{dimension.name}`: buckets need a model with a single-column primary key; " \
123
+ "`#{model.table_name}` has #{Portolan.quoted(Array(model.primary_key))}")
124
+ end
125
+
126
+ def invalid!(problem)
127
+ raise ArgumentError, "dataset `#{name || 'anonymous'}`: #{problem}"
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Portolan
4
+ class Dimension
5
+ class Bucket
6
+ OPTIONS = %i[where description].freeze
7
+
8
+ attr_reader :name, :where, :description
9
+
10
+ def initialize(name, options)
11
+ @name = name.to_s
12
+ options = { where: options } unless options.is_a?(Hash)
13
+ check!(options)
14
+ @where = options[:where]
15
+ @description = options[:description].presence
16
+ freeze
17
+ end
18
+
19
+ def apply(scope)
20
+ primary_key = scope.klass.primary_key
21
+ scope.where(primary_key => where.call.select(primary_key))
22
+ end
23
+
24
+ private
25
+
26
+ def check!(options)
27
+ unknown = options.keys - OPTIONS
28
+ invalid!("unknown option #{Portolan.quoted(unknown.map(&:inspect))}") if unknown.any?
29
+ invalid!('needs a `where` that responds to `call`') unless options[:where].respond_to?(:call)
30
+ end
31
+
32
+ def invalid!(problem)
33
+ raise ArgumentError, "bucket `#{name}`: #{problem}"
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Portolan
4
+ class Dimension
5
+ OPTIONS = %i[type values joins expression buckets].freeze
6
+
7
+ attr_reader :name, :description, :type, :joins, :expression, :buckets
8
+
9
+ def initialize(name, description, **options)
10
+ @name = name.to_s
11
+ check_options!(options.keys, description)
12
+ @description = description
13
+ @joins = options[:joins]
14
+ @expression = options[:expression]
15
+ @buckets = build_buckets(options[:buckets])
16
+ @type = build_type(options)
17
+ invalid!('`joins` needs an `expression` naming the joined column') if joins && expression.nil?
18
+ freeze
19
+ end
20
+
21
+ def values = type.values
22
+ def operators = type.operators
23
+ def periods = type.periods
24
+ def time? = periods.any?
25
+ def buckets? = !buckets.nil?
26
+
27
+ def describe
28
+ {
29
+ name:,
30
+ type: type.name,
31
+ description:,
32
+ operators:,
33
+ values:,
34
+ periods: periods.presence,
35
+ buckets: bucket_descriptions.presence,
36
+ }.compact
37
+ end
38
+
39
+ def join(scope) = joins ? scope.left_joins(joins) : scope
40
+
41
+ def column(scope)
42
+ return scope.arel_table[name] unless expression
43
+
44
+ Arel::Nodes::Grouping.new(Arel.sql(expression))
45
+ end
46
+
47
+ def definition
48
+ { description:, buckets: bucket_descriptions.presence }.compact
49
+ end
50
+
51
+ private
52
+
53
+ def build_type(options)
54
+ type_name = options.fetch(:type, :string)
55
+ unless Type::NAMES.include?(type_name)
56
+ invalid!("unknown type `#{type_name.inspect}` (allowed: #{Type::NAMES.map(&:inspect).join(', ')})")
57
+ end
58
+ return bucket_type(type_name, options) if buckets?
59
+ return Type.for(type_name) if options[:values].nil?
60
+
61
+ invalid!('values must be a non-empty array') unless options[:values].is_a?(Array) && options[:values].any?
62
+ invalid!('values are only for `:string`') unless type_name == :string
63
+ Type::Enum.new(options[:values])
64
+ end
65
+
66
+ def bucket_type(type_name, options)
67
+ invalid!('buckets are always `:string`') unless type_name == :string
68
+ invalid!('buckets cannot have `values`, `joins`, or `expression`') if options[:values] || joins || expression
69
+ Type::Enum.new(buckets.keys)
70
+ end
71
+
72
+ def build_buckets(bucket_options)
73
+ return if bucket_options.nil?
74
+
75
+ invalid!('buckets must be a hash of names to conditions') unless bucket_options.is_a?(Hash)
76
+ invalid!('needs at least one bucket') if bucket_options.empty?
77
+ duplicate = bucket_options.keys.map(&:to_s).tally.find { |_, count| count > 1 }&.first
78
+ invalid!("bucket `#{duplicate}` is declared twice") if duplicate
79
+
80
+ bucket_options.to_h { |bucket_name, options| [bucket_name.to_s, build_bucket(bucket_name, options)] }.freeze
81
+ end
82
+
83
+ def build_bucket(bucket_name, options)
84
+ Bucket.new(bucket_name, options)
85
+ rescue ArgumentError => e
86
+ invalid!(e.message)
87
+ end
88
+
89
+ def bucket_descriptions
90
+ buckets.to_h.values.select(&:description).to_h { |bucket| [bucket.name, bucket.description] }
91
+ end
92
+
93
+ def check_options!(option_names, description)
94
+ raise ArgumentError, 'a dimension needs a name' if name.blank?
95
+
96
+ unknown = option_names - OPTIONS
97
+ invalid!("unknown option #{Portolan.quoted(unknown.map(&:inspect))}") if unknown.any?
98
+ invalid!('needs a description') unless description.is_a?(String) && description.present?
99
+ end
100
+
101
+ def invalid!(problem)
102
+ raise ArgumentError, "dimension `#{name}`: #{problem}"
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Portolan
4
+ class Measure
5
+ AGGREGATES = %i[count_distinct sum average].freeze
6
+
7
+ attr_reader :name, :description, :aggregate, :column
8
+
9
+ def initialize(name, description, **options)
10
+ @name = name.to_s
11
+ check!(options, description)
12
+ @description = description
13
+ @aggregate, @column = options.first || [:count, nil]
14
+ @column = @column&.to_s
15
+ freeze
16
+ end
17
+
18
+ def call(scope)
19
+ case aggregate
20
+ when :count then scope.count
21
+ when :count_distinct then scope.distinct.count(column.to_sym)
22
+ when :sum then scope.sum(column.to_sym)
23
+ when :average then scope.average(column.to_sym)
24
+ end
25
+ end
26
+
27
+ def describe = { name:, description: }
28
+ def label(value) = value.is_a?(::BigDecimal) ? value.to_f : value
29
+
30
+ private
31
+
32
+ def check!(options, description)
33
+ raise ArgumentError, 'a measure needs a name' if name.blank?
34
+
35
+ unknown = options.keys - AGGREGATES
36
+ invalid!("unknown option #{Portolan.quoted(unknown.map(&:inspect))}") if unknown.any?
37
+ invalid!('needs a description') unless description.is_a?(String) && description.present?
38
+ invalid!("takes one of #{Portolan.quoted(AGGREGATES)}, not several") if options.size > 1
39
+
40
+ options.each do |kind, column|
41
+ next if (column.is_a?(Symbol) || column.is_a?(String)) && column.present?
42
+
43
+ invalid!("`#{kind}` needs a column name")
44
+ end
45
+ end
46
+
47
+ def invalid!(problem)
48
+ raise ArgumentError, "measure `#{name}`: #{problem}"
49
+ end
50
+ end
51
+ end