sequel-notion 0.2.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 +7 -0
- data/CHANGELOG.md +207 -0
- data/LICENSE.txt +21 -0
- data/README.md +470 -0
- data/lib/sequel/adapters/notion.rb +159 -0
- data/lib/sequel/notion/dataset.rb +167 -0
- data/lib/sequel/notion/dataset_aggregates.rb +82 -0
- data/lib/sequel/notion/dataset_compounds.rb +89 -0
- data/lib/sequel/notion/dataset_computed.rb +124 -0
- data/lib/sequel/notion/dataset_grouping.rb +145 -0
- data/lib/sequel/notion/dataset_having.rb +99 -0
- data/lib/sequel/notion/dataset_joins.rb +124 -0
- data/lib/sequel/notion/dataset_pages.rb +145 -0
- data/lib/sequel/notion/dataset_selection.rb +51 -0
- data/lib/sequel/notion/dataset_truncation.rb +39 -0
- data/lib/sequel/notion/discovery.rb +76 -0
- data/lib/sequel/notion/errors.rb +9 -0
- data/lib/sequel/notion/filter_comparison.rb +123 -0
- data/lib/sequel/notion/filter_compiler.rb +121 -0
- data/lib/sequel/notion/filter_constants.rb +37 -0
- data/lib/sequel/notion/filter_helpers.rb +162 -0
- data/lib/sequel/notion/filter_like.rb +143 -0
- data/lib/sequel/notion/filter_like_tokenizer.rb +42 -0
- data/lib/sequel/notion/filter_negation.rb +76 -0
- data/lib/sequel/notion/filter_nested.rb +96 -0
- data/lib/sequel/notion/filter_nulls.rb +53 -0
- data/lib/sequel/notion/filter_predicates.rb +160 -0
- data/lib/sequel/notion/filter_shape.rb +75 -0
- data/lib/sequel/notion/filter_tables.rb +111 -0
- data/lib/sequel/notion/group_accumulator.rb +55 -0
- data/lib/sequel/notion/join_output.rb +58 -0
- data/lib/sequel/notion/join_where.rb +111 -0
- data/lib/sequel/notion/model_support.rb +52 -0
- data/lib/sequel/notion/notion_file.rb +228 -0
- data/lib/sequel/notion/page_api.rb +49 -0
- data/lib/sequel/notion/registry.rb +150 -0
- data/lib/sequel/notion/request_budget.rb +64 -0
- data/lib/sequel/notion/schema.rb +74 -0
- data/lib/sequel/notion/schema_lookup.rb +82 -0
- data/lib/sequel/notion/sort_compiler.rb +63 -0
- data/lib/sequel/notion/type_map.rb +125 -0
- data/lib/sequel/notion/type_map_builders.rb +125 -0
- data/lib/sequel/notion/type_map_dates.rb +67 -0
- data/lib/sequel/notion/type_map_extractors.rb +89 -0
- data/lib/sequel/notion/version.rb +7 -0
- data/lib/sequel/notion.rb +5 -0
- metadata +132 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sequel
|
|
4
|
+
module Notion
|
|
5
|
+
# HAVING over the groups DatasetGrouping computes, evaluated in
|
|
6
|
+
# Ruby with SQL's three-valued logic: a comparison with NULL is
|
|
7
|
+
# unknown, and only a true condition keeps a group. An aggregate
|
|
8
|
+
# it writes out (count(*) > 1) is computed as a hidden output.
|
|
9
|
+
module DatasetHaving
|
|
10
|
+
COMPARE = { "=": :==, "!=": :!=, "<": :<, ">": :>, "<=": :<=,
|
|
11
|
+
">=": :>= }.freeze
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def having_functions(expr = @opts[:having], found = [])
|
|
16
|
+
case expr
|
|
17
|
+
when SQL::Function
|
|
18
|
+
found << expr unless found.include?(expr)
|
|
19
|
+
when SQL::ComplexExpression, Array
|
|
20
|
+
(expr.is_a?(Array) ? expr : expr.args)
|
|
21
|
+
.each { having_functions(it, found) }
|
|
22
|
+
end
|
|
23
|
+
found
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def having_name(index) = :"__having_#{index}"
|
|
27
|
+
|
|
28
|
+
def having?(row, funcs)
|
|
29
|
+
having_value(@opts[:having], row, funcs) == true
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def having_value(expr, row, funcs)
|
|
33
|
+
case expr
|
|
34
|
+
when SQL::Function then row[having_name(funcs.index(expr))]
|
|
35
|
+
when SQL::BooleanExpression
|
|
36
|
+
having_boolean(expr, row, funcs)
|
|
37
|
+
when Symbol, SQL::Identifier, SQL::QualifiedIdentifier
|
|
38
|
+
having_column(expr, row)
|
|
39
|
+
when Array then expr.map { having_value(it, row, funcs) }
|
|
40
|
+
else having_literal(expr, row, funcs)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def having_literal(expr, row, funcs)
|
|
45
|
+
return expr unless expr.is_a?(SQL::Expression)
|
|
46
|
+
if expr.is_a?(SQL::ComplexExpression) && expr.op == :NOOP
|
|
47
|
+
return having_value(expr.args.first, row, funcs)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
raise Error, "Unsupported HAVING expression: #{expr.inspect}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def having_column(expr, row)
|
|
54
|
+
name = group_column(expr)
|
|
55
|
+
return row[name] if row.key?(name)
|
|
56
|
+
|
|
57
|
+
raise Error,
|
|
58
|
+
"HAVING names #{name}, neither grouped nor selected"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def having_boolean(expr, row, funcs)
|
|
62
|
+
values = expr.args.map { having_value(it, row, funcs) }
|
|
63
|
+
case expr.op
|
|
64
|
+
when :AND, :OR, :NOT then logical(expr.op, values)
|
|
65
|
+
when :IS, :"IS NOT" then identity(expr.op, *values)
|
|
66
|
+
when :IN, :"NOT IN" then in_list(expr.op, *values)
|
|
67
|
+
else compared(expr.op, *values)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def logical(op, values)
|
|
72
|
+
return (values.first.nil? ? nil : !values.first) if op == :NOT
|
|
73
|
+
|
|
74
|
+
decisive = op != :AND
|
|
75
|
+
return decisive if values.include?(decisive)
|
|
76
|
+
|
|
77
|
+
values.include?(nil) ? nil : !decisive
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def identity(op, left, right)
|
|
81
|
+
same = right.nil? ? left.nil? : left == right
|
|
82
|
+
op == :IS ? same : !same
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def in_list(op, left, list)
|
|
86
|
+
found = list.include?(left) unless left.nil?
|
|
87
|
+
found = nil if !found && (left.nil? || list.include?(nil))
|
|
88
|
+
op == :IN || found.nil? ? found : !found
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def compared(op, left, right)
|
|
92
|
+
method = COMPARE.fetch(op) do
|
|
93
|
+
raise Error, "Unsupported HAVING operator: #{op}"
|
|
94
|
+
end
|
|
95
|
+
left.nil? || right.nil? ? nil : left.public_send(method, right)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sequel/notion/join_output"
|
|
4
|
+
require "sequel/notion/join_where"
|
|
5
|
+
require "sequel/notion/registry"
|
|
6
|
+
|
|
7
|
+
module Sequel
|
|
8
|
+
module Notion
|
|
9
|
+
# INNER and LEFT joins on one equality, which Notion's API does
|
|
10
|
+
# not compute: each source is queried with the conditions that
|
|
11
|
+
# test it alone, then rows are matched in Ruby. A relation (an
|
|
12
|
+
# Array of page ids) matches every page it lists. Without a
|
|
13
|
+
# SELECT, a later table's column wins a shared name, as with SQL
|
|
14
|
+
# adapters.
|
|
15
|
+
module DatasetJoins
|
|
16
|
+
include JoinOutput
|
|
17
|
+
include JoinWhere
|
|
18
|
+
|
|
19
|
+
JOIN_TYPES = %i[inner left left_outer].freeze
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def joined? = !@opts[:join].nil?
|
|
24
|
+
|
|
25
|
+
def join_rows(&)
|
|
26
|
+
sources = join_sources
|
|
27
|
+
rows = joined_rows(sources).map { join_output(it, sources) }
|
|
28
|
+
rows = sort_rows(rows).drop(@opts[:offset] || 0)
|
|
29
|
+
rows = rows.take(@opts[:limit]) if @opts[:limit]
|
|
30
|
+
rows.each(&)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# { source name => row }, one per match
|
|
34
|
+
def joined_rows(sources)
|
|
35
|
+
wheres = split_where(sources)
|
|
36
|
+
first = sources.first
|
|
37
|
+
rows = source_rows(first, wheres)
|
|
38
|
+
.map { { first.first => it } }
|
|
39
|
+
@opts[:join].each_with_index.reduce(rows) do |acc, (clause, i)|
|
|
40
|
+
joined(acc, clause, sources[i + 1], wheres)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def join_sources
|
|
45
|
+
[[source_table.to_s, source_table]] +
|
|
46
|
+
@opts[:join].map do |clause|
|
|
47
|
+
table = clause.table
|
|
48
|
+
table = table.value if table.is_a?(SQL::Identifier)
|
|
49
|
+
[(clause.table_alias || table).to_s, table.to_sym]
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def source_rows((name, table), wheres)
|
|
54
|
+
ds = db[table]
|
|
55
|
+
Array(wheres[name]).each { ds = ds.where(it) }
|
|
56
|
+
ds.naked.all
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def joined(rows, clause, (name, table), wheres)
|
|
60
|
+
left, right = join_columns(clause, name)
|
|
61
|
+
if clause.join_type != :inner && wheres[name]
|
|
62
|
+
return left_joined(rows, left, right, [name, table],
|
|
63
|
+
wheres)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
index = join_index(source_rows([name, table], wheres), right)
|
|
67
|
+
rows.flat_map { matched(it, left, index, clause, name) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def join_index(rows, right)
|
|
71
|
+
index = Hash.new { |h, k| h[k] = [] }
|
|
72
|
+
rows.each do |row|
|
|
73
|
+
join_keys(row[right]).each { index[it] << row }
|
|
74
|
+
end
|
|
75
|
+
index
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def matched(row, (table, column), index, clause, name)
|
|
79
|
+
found = partners(row, table, column, index)
|
|
80
|
+
return found.map { row.merge(name => it) } unless found.empty?
|
|
81
|
+
|
|
82
|
+
clause.join_type == :inner ? [] : [row.merge(name => nil)]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def partners(row, table, column, index)
|
|
86
|
+
join_keys(row[table]&.[](column)).flat_map { index[it] }.uniq
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# [[left source, column], right column] of an ON a = b
|
|
90
|
+
def join_columns(clause, name)
|
|
91
|
+
args = join_on!(clause).args
|
|
92
|
+
right, left = args.partition { it.table.to_s == name }
|
|
93
|
+
unless right.size == 1
|
|
94
|
+
raise Error, "a join's ON must test the joined table once"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
[[left.first.table.to_s, left.first.column.to_sym],
|
|
98
|
+
right.first.column.to_sym]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def join_on!(clause)
|
|
102
|
+
on = clause.on if clause.is_a?(SQL::JoinOnClause)
|
|
103
|
+
return on if JOIN_TYPES.include?(clause.join_type) &&
|
|
104
|
+
equality?(on)
|
|
105
|
+
|
|
106
|
+
raise Error, "Unsupported join: #{clause.inspect}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def equality?(on)
|
|
110
|
+
on.is_a?(SQL::BooleanExpression) && on.op == :"=" &&
|
|
111
|
+
on.args.all?(SQL::QualifiedIdentifier)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# A value's join keys: a relation's ids, page ids without
|
|
115
|
+
# dashes and case
|
|
116
|
+
def join_keys(value)
|
|
117
|
+
Array(value).map do |v|
|
|
118
|
+
uuid = v.is_a?(String) && v.match?(Registry::UUID)
|
|
119
|
+
uuid ? v.delete("-").downcase : v
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sequel/notion/errors"
|
|
4
|
+
require "sequel/notion/filter_compiler"
|
|
5
|
+
require "sequel/notion/sort_compiler"
|
|
6
|
+
|
|
7
|
+
module Sequel
|
|
8
|
+
module Notion
|
|
9
|
+
# Where a dataset's pages come from: a paginated data source
|
|
10
|
+
# query, or a GET per page when WHERE pins the page id.
|
|
11
|
+
module DatasetPages
|
|
12
|
+
# Clauses Notion cannot express; refused rather than ignored
|
|
13
|
+
UNSUPPORTED = %i[join group having compounds lock].freeze
|
|
14
|
+
|
|
15
|
+
PAGE_SIZE = 100
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def page_ids
|
|
20
|
+
ids = []
|
|
21
|
+
each_notion_page { ids << it["id"] }
|
|
22
|
+
ids
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Yield raw pages, after OFFSET and up to LIMIT
|
|
26
|
+
def each_notion_page
|
|
27
|
+
check_supported!
|
|
28
|
+
skip = @opts[:offset] || 0
|
|
29
|
+
max = @opts[:limit]
|
|
30
|
+
seen = 0
|
|
31
|
+
source_pages do |page|
|
|
32
|
+
next if (skip -= 1) >= 0
|
|
33
|
+
|
|
34
|
+
yield page
|
|
35
|
+
break if max && (seen += 1) >= max
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def check_supported!
|
|
40
|
+
bad = UNSUPPORTED.select { @opts[it] }
|
|
41
|
+
return if bad.empty?
|
|
42
|
+
|
|
43
|
+
raise Error, "Notion datasets do not support: " \
|
|
44
|
+
"#{bad.join(", ")}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def source_pages(&)
|
|
48
|
+
ds_id = data_source_id
|
|
49
|
+
ids, rest = split_id_condition(@opts[:where])
|
|
50
|
+
return pages_by_id(ds_id, ids, rest, &) if ids
|
|
51
|
+
|
|
52
|
+
filter = @opts[:where] &&
|
|
53
|
+
FilterCompiler.for(db, ds_id).compile(@opts[:where])
|
|
54
|
+
query_pages(ds_id, filter, &) unless
|
|
55
|
+
filter.equal?(FilterCompiler::NOTHING)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def query_pages(ds_id, filter)
|
|
59
|
+
cursor = nil
|
|
60
|
+
loop do
|
|
61
|
+
resp = db.notion_query(ds_id, query_body(filter, cursor))
|
|
62
|
+
(resp["results"] || []).each { yield it }
|
|
63
|
+
break unless resp["has_more"]
|
|
64
|
+
|
|
65
|
+
cursor = resp["next_cursor"]
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def query_body(filter, cursor)
|
|
70
|
+
body = { page_size: notion_page_size }
|
|
71
|
+
body[:filter] = filter if filter
|
|
72
|
+
body[:sorts] = SortCompiler.compile(@opts[:order]) if
|
|
73
|
+
@opts[:order]
|
|
74
|
+
body[:start_cursor] = cursor if cursor
|
|
75
|
+
body
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def notion_page_size
|
|
79
|
+
offset = @opts[:offset] || 0
|
|
80
|
+
want = @opts[:limit] && (@opts[:limit] + offset)
|
|
81
|
+
[want || PAGE_SIZE, @opts[:notion_page_size] || PAGE_SIZE,
|
|
82
|
+
PAGE_SIZE].min
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# ----------------------------------------------------------
|
|
86
|
+
# Lookup by page id
|
|
87
|
+
# ----------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
# [ids, other conditions] when WHERE pins the id, else nil
|
|
90
|
+
def split_id_condition(where)
|
|
91
|
+
return unless where.is_a?(SQL::BooleanExpression)
|
|
92
|
+
return id_and_rest(where.args) if where.op == :AND
|
|
93
|
+
|
|
94
|
+
ids = id_values(where)
|
|
95
|
+
[ids, []] if ids
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def id_and_rest(conds)
|
|
99
|
+
sets = conds.filter_map { id_values(it) }
|
|
100
|
+
return if sets.empty?
|
|
101
|
+
|
|
102
|
+
[sets.reduce(:&), conds.reject { id_values(it) }]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def id_values(expr)
|
|
106
|
+
return unless expr.is_a?(SQL::BooleanExpression)
|
|
107
|
+
return unless %i[= IN].include?(expr.op)
|
|
108
|
+
return unless FilterCompiler.property_name(expr.args[0]) ==
|
|
109
|
+
"id"
|
|
110
|
+
|
|
111
|
+
Array(expr.args[1]).map { bare_id(it) }.uniq
|
|
112
|
+
rescue Error
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def pages_by_id(ds_id, ids, rest)
|
|
117
|
+
unless rest.empty?
|
|
118
|
+
raise Error, "an id lookup cannot be combined with " \
|
|
119
|
+
"other conditions"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
pages = ids.lazy.filter_map { fetch_page(it) }
|
|
123
|
+
.select { in_source?(it, ds_id) }
|
|
124
|
+
pages = ordered_pages(pages.to_a) if @opts[:order]
|
|
125
|
+
pages.each { yield it }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Trashed pages included: the row says so, and this is the
|
|
129
|
+
# only way to reach one to restore it
|
|
130
|
+
def fetch_page(id)
|
|
131
|
+
db.notion_get_page(id)
|
|
132
|
+
rescue NotFoundError
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def in_source?(page, ds_id)
|
|
137
|
+
parent = page["parent"] || {}
|
|
138
|
+
parent["type"] == "data_source_id" &&
|
|
139
|
+
bare_id(parent["data_source_id"]) == bare_id(ds_id)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def bare_id(id) = id.to_s.delete("-").downcase
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sequel/notion/filter_compiler"
|
|
4
|
+
|
|
5
|
+
module Sequel
|
|
6
|
+
module Notion
|
|
7
|
+
# SELECT for Notion datasets: plain columns, optionally aliased
|
|
8
|
+
module DatasetSelection
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
# [[source column, output name], ...], or nil for every column
|
|
12
|
+
def selection
|
|
13
|
+
sel = @opts[:select]
|
|
14
|
+
return if sel.nil? || sel.empty?
|
|
15
|
+
return if sel.any? { it == :* || it.is_a?(SQL::ColumnAll) }
|
|
16
|
+
|
|
17
|
+
known!(sel.map { selected(it) })
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def known!(pairs)
|
|
21
|
+
unknown = pairs.map(&:first) -
|
|
22
|
+
db.schema(source_table).map(&:first)
|
|
23
|
+
return pairs if unknown.empty?
|
|
24
|
+
|
|
25
|
+
raise Error, "Unknown columns in select: #{unknown.join(", ")}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def selected(expr)
|
|
29
|
+
if expr.is_a?(SQL::AliasedExpression)
|
|
30
|
+
[column_name(expr.expression), expr.alias.to_sym]
|
|
31
|
+
else
|
|
32
|
+
name = column_name(expr)
|
|
33
|
+
[name, name]
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def column_name(expr)
|
|
38
|
+
FilterCompiler.property_name(expr).to_sym
|
|
39
|
+
rescue Error
|
|
40
|
+
raise Error, "Unsupported select expression: " \
|
|
41
|
+
"#{expr.inspect} (only columns can be selected)"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def project(row, sel)
|
|
45
|
+
return row unless sel
|
|
46
|
+
|
|
47
|
+
sel.to_h { |source, name| [name, row[source]] }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sequel
|
|
4
|
+
module Notion
|
|
5
|
+
# A page lists at most 25 relations or people; the rest is read
|
|
6
|
+
# from the page property endpoint, for the properties a row
|
|
7
|
+
# returns. A relation says has_more; people say nothing, so 25
|
|
8
|
+
# of them may be more.
|
|
9
|
+
module DatasetTruncation
|
|
10
|
+
LISTED = 25
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def complete_page(page, sel)
|
|
15
|
+
wanted = sel&.map { it.first.to_s }
|
|
16
|
+
(page["properties"] || {}).each do |name, prop|
|
|
17
|
+
next if wanted && !wanted.include?(name)
|
|
18
|
+
|
|
19
|
+
complete_property(page["id"], prop) if truncated?(prop)
|
|
20
|
+
end
|
|
21
|
+
page
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def complete_property(page_id, prop)
|
|
25
|
+
type = prop["type"]
|
|
26
|
+
prop[type] = db.notion_property_items(page_id, prop["id"])
|
|
27
|
+
.map { it[type] }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def truncated?(prop)
|
|
31
|
+
case prop["type"]
|
|
32
|
+
when "relation" then prop["has_more"] == true
|
|
33
|
+
when "people" then Array(prop["people"]).size >= LISTED
|
|
34
|
+
else false
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sequel
|
|
4
|
+
module Notion
|
|
5
|
+
# Listing the data sources the integration can see
|
|
6
|
+
module Discovery
|
|
7
|
+
# List data sources: those of one database, or a search
|
|
8
|
+
def data_sources(database: nil, query: nil)
|
|
9
|
+
if database && query
|
|
10
|
+
raise ArgumentError, "database and query are exclusive"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
database ? database_sources(database) : search_sources(query)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
# Notion's search still lists a trashed data source, flagged
|
|
19
|
+
def live(sources) = sources.reject { it[:in_trash] }
|
|
20
|
+
|
|
21
|
+
# Ids of the live data sources whose title normalises to name
|
|
22
|
+
def matching_source_ids(name)
|
|
23
|
+
wanted = Registry.normalize(name)
|
|
24
|
+
return [] if wanted.empty?
|
|
25
|
+
|
|
26
|
+
live(search_sources(name.to_s.tr("_", " ")))
|
|
27
|
+
.select { Registry.normalize(it[:name]) == wanted }
|
|
28
|
+
.map { it[:id] }.uniq
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def database_sources(database)
|
|
32
|
+
resp = request(:get, "databases/#{database}")
|
|
33
|
+
(resp["data_sources"] || []).map do |ds|
|
|
34
|
+
ds.slice("id", "name").transform_keys(&:to_sym)
|
|
35
|
+
.merge(parent_database_id: database)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def search_sources(query)
|
|
40
|
+
sources = []
|
|
41
|
+
cursor = nil
|
|
42
|
+
loop do
|
|
43
|
+
resp = request(:post, "search", search_body(query, cursor))
|
|
44
|
+
sources.concat(source_entries(resp["results"]))
|
|
45
|
+
break unless resp["has_more"]
|
|
46
|
+
|
|
47
|
+
cursor = resp["next_cursor"]
|
|
48
|
+
end
|
|
49
|
+
sources
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def source_entries(results)
|
|
53
|
+
(results || []).filter_map do |entry|
|
|
54
|
+
source_entry(entry) if entry["object"] == "data_source"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def search_body(query, cursor)
|
|
59
|
+
body = { page_size: 100,
|
|
60
|
+
filter: { property: "object", value: "data_source" },
|
|
61
|
+
sort: { direction: "descending",
|
|
62
|
+
timestamp: "last_edited_time" } }
|
|
63
|
+
body[:query] = query if query
|
|
64
|
+
body[:start_cursor] = cursor if cursor
|
|
65
|
+
body
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def source_entry(ds)
|
|
69
|
+
ds.slice("id", "icon", "url", "in_trash", "properties")
|
|
70
|
+
.transform_keys(&:to_sym)
|
|
71
|
+
.merge(parent_database_id: ds.dig("parent", "database_id"),
|
|
72
|
+
name: ds["title"]&.map { it["plain_text"] }&.join)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "sequel/notion/filter_tables"
|
|
5
|
+
|
|
6
|
+
module Sequel
|
|
7
|
+
module Notion
|
|
8
|
+
# Standard and date-inequality (rule D) comparison compilation,
|
|
9
|
+
# mixed into FilterCompiler; formulas and rollups are FilterNested's.
|
|
10
|
+
# All private.
|
|
11
|
+
module FilterComparison
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
# ----------------------------------------------------------
|
|
15
|
+
# Standard comparison
|
|
16
|
+
# ----------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
def compile_comparison(expr)
|
|
19
|
+
case expr.op
|
|
20
|
+
when :IN, :"NOT IN" then return compile_in(expr)
|
|
21
|
+
when :LIKE, :ILIKE, :"NOT LIKE", :"NOT ILIKE"
|
|
22
|
+
return compile_like(expr)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
name, op, value = comparison_operands(expr)
|
|
26
|
+
return null_comparison_filter(name, op) if value.nil?
|
|
27
|
+
|
|
28
|
+
type = lookup_type!(name)
|
|
29
|
+
key = filter_key_for!(name, type)
|
|
30
|
+
|
|
31
|
+
compile_typed_comparison(name, op, value, key)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Swaps a comparison's operands into column-then-value order
|
|
35
|
+
# (rule S): a < b == b > a
|
|
36
|
+
def comparison_operands(expr)
|
|
37
|
+
op = expr.op
|
|
38
|
+
left, right = expr.args
|
|
39
|
+
|
|
40
|
+
if !column_expr?(left) && column_expr?(right)
|
|
41
|
+
left, right = right, left
|
|
42
|
+
op = mirrored_operator(op)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
[FilterCompiler.property_name(left), op,
|
|
46
|
+
literal_value!(unwrap_literal(right))]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Notion compares a property with a value, never with another
|
|
50
|
+
# property or an expression
|
|
51
|
+
def literal_value!(value)
|
|
52
|
+
return value unless bare_column_expr?(value) ||
|
|
53
|
+
value.is_a?(Sequel::SQL::Expression)
|
|
54
|
+
|
|
55
|
+
raise Sequel::Error,
|
|
56
|
+
"Notion filters compare with values only: " \
|
|
57
|
+
"#{value.inspect}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def mirrored_operator(op)
|
|
61
|
+
FilterTables::MIRROR_OPERATOR.fetch(op) do
|
|
62
|
+
raise Sequel::Error, "Unsupported operator: #{op}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def null_comparison_filter(name, op)
|
|
67
|
+
case op
|
|
68
|
+
when :"=" then null_check_filter(name, empty: true)
|
|
69
|
+
when :"!=" then null_check_filter(name, empty: false)
|
|
70
|
+
else
|
|
71
|
+
raise Sequel::Error,
|
|
72
|
+
"Unsupported nil comparison operator: #{op}"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def compile_typed_comparison(name, op, value, key)
|
|
77
|
+
if FilterNested::NESTED_KEYS.include?(key)
|
|
78
|
+
return compile_nested_key(name, op, value, key)
|
|
79
|
+
end
|
|
80
|
+
if key == "date" && op == :"!="
|
|
81
|
+
return date_not_equal_filter(name, value)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
notion_op = translated_operator(op, key)
|
|
85
|
+
ensure_supported!(name, key, notion_op)
|
|
86
|
+
|
|
87
|
+
{ "property" => name,
|
|
88
|
+
key => { notion_op => coerce_value(value, key) } }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Looks up the Notion operator for `op`, remapping it for date
|
|
92
|
+
# properties and for the types that express (not)equal through
|
|
93
|
+
# contains/does_not_contain (rule IN).
|
|
94
|
+
def translated_operator(op, key)
|
|
95
|
+
notion_op = FilterTables::OPERATOR_MAP.fetch(op) do
|
|
96
|
+
raise Sequel::Error, "Unsupported operator: #{op}"
|
|
97
|
+
end
|
|
98
|
+
if key == "date"
|
|
99
|
+
notion_op = FilterTables::DATE_OPERATOR_MAP.fetch(
|
|
100
|
+
notion_op, notion_op
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
contains_type?(key) ? equal_to_contains(notion_op) : notion_op
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# ----------------------------------------------------------
|
|
107
|
+
# Date inequality (rule D)
|
|
108
|
+
# ----------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
def date_not_equal_filter(name, value)
|
|
111
|
+
coerced = coerce_value(value, "date")
|
|
112
|
+
{
|
|
113
|
+
"or" => [
|
|
114
|
+
{ "property" => name,
|
|
115
|
+
"date" => { "before" => coerced } },
|
|
116
|
+
{ "property" => name,
|
|
117
|
+
"date" => { "after" => coerced } }
|
|
118
|
+
]
|
|
119
|
+
}
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|