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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +202 -0
- data/lib/portolan/dataset.rb +131 -0
- data/lib/portolan/dimension/bucket.rb +37 -0
- data/lib/portolan/dimension.rb +105 -0
- data/lib/portolan/measure.rb +51 -0
- data/lib/portolan/query/aggregate.rb +165 -0
- data/lib/portolan/query/base.rb +116 -0
- data/lib/portolan/query/filter.rb +126 -0
- data/lib/portolan/query/guard.rb +38 -0
- data/lib/portolan/query/result.rb +39 -0
- data/lib/portolan/registry.rb +66 -0
- data/lib/portolan/type/base.rb +17 -0
- data/lib/portolan/type/boolean.rb +13 -0
- data/lib/portolan/type/date.rb +33 -0
- data/lib/portolan/type/date_time.rb +15 -0
- data/lib/portolan/type/decimal.rb +24 -0
- data/lib/portolan/type/enum.rb +22 -0
- data/lib/portolan/type/integer.rb +27 -0
- data/lib/portolan/type/string.rb +13 -0
- data/lib/portolan/type.rb +17 -0
- data/lib/portolan/version.rb +5 -0
- data/lib/portolan.rb +32 -0
- metadata +108 -0
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
|