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,228 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Sequel
|
|
6
|
+
module Notion
|
|
7
|
+
# File's "Notion API → File instances" class methods, split out
|
|
8
|
+
# here (and mixed in with `extend`) to keep File's own body
|
|
9
|
+
# under the class-length limit.
|
|
10
|
+
module FileDeserialization
|
|
11
|
+
# Parse a single file object from Notion API response
|
|
12
|
+
#
|
|
13
|
+
# File.from_notion({
|
|
14
|
+
# "type" => "external",
|
|
15
|
+
# "name" => "doc.pdf",
|
|
16
|
+
# "external" => { "url" => "https://..." }
|
|
17
|
+
# })
|
|
18
|
+
#
|
|
19
|
+
def from_notion(file_obj)
|
|
20
|
+
return nil unless file_obj.is_a?(Hash)
|
|
21
|
+
|
|
22
|
+
case file_obj["type"]
|
|
23
|
+
when "file" then from_notion_file(file_obj)
|
|
24
|
+
when "external" then from_notion_external(file_obj)
|
|
25
|
+
when nil then raise Sequel::Error, "file object with no type"
|
|
26
|
+
else from_notion_other(file_obj)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Parse the "files" property value → array of File
|
|
31
|
+
#
|
|
32
|
+
# File.from_notion_property(prop)
|
|
33
|
+
# # => [#<File name="doc.pdf" url="https://...">, ...]
|
|
34
|
+
#
|
|
35
|
+
def from_notion_property(prop)
|
|
36
|
+
files = prop["files"] || []
|
|
37
|
+
files.filter_map { |f| from_notion(f) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def extract_caption(file_obj)
|
|
43
|
+
file_obj["caption"]&.map { |t| t["plain_text"] }&.join
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# from_notion's three branches, split out to keep from_notion
|
|
47
|
+
# itself short.
|
|
48
|
+
|
|
49
|
+
def from_notion_file(file_obj)
|
|
50
|
+
new(
|
|
51
|
+
name: file_obj["name"],
|
|
52
|
+
url: file_obj.dig("file", "url"),
|
|
53
|
+
type: :file,
|
|
54
|
+
expiry_time: file_obj.dig("file", "expiry_time"),
|
|
55
|
+
caption: extract_caption(file_obj)
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def from_notion_external(file_obj)
|
|
60
|
+
new(
|
|
61
|
+
name: file_obj["name"],
|
|
62
|
+
url: file_obj.dig("external", "url"),
|
|
63
|
+
type: :external,
|
|
64
|
+
caption: extract_caption(file_obj)
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# A type this adapter does not know yet (Notion added one
|
|
69
|
+
# after this was written): round-trip it opaquely via @raw
|
|
70
|
+
def from_notion_other(file_obj)
|
|
71
|
+
type = file_obj["type"]
|
|
72
|
+
new(
|
|
73
|
+
name: file_obj["name"],
|
|
74
|
+
url: file_obj.dig(type, "url"),
|
|
75
|
+
type: type,
|
|
76
|
+
caption: extract_caption(file_obj),
|
|
77
|
+
raw: file_obj
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
class File
|
|
83
|
+
extend FileDeserialization
|
|
84
|
+
|
|
85
|
+
attr_reader :name, :url, :expiry_time, :type, :caption, :raw
|
|
86
|
+
|
|
87
|
+
# Keywords besides url:, name: and type:
|
|
88
|
+
OPTIONS = %i[expiry_time caption raw].freeze
|
|
89
|
+
|
|
90
|
+
# type: :file (Notion-hosted) or :external; any other is kept
|
|
91
|
+
# with its raw hash, so that it is written back unchanged
|
|
92
|
+
def initialize(url:, name: nil, type: :external, **opts)
|
|
93
|
+
unknown = opts.keys - OPTIONS
|
|
94
|
+
unless unknown.empty?
|
|
95
|
+
raise ArgumentError, "unknown keywords: #{unknown.inspect}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
@name = name
|
|
99
|
+
@url = url
|
|
100
|
+
@type = type.to_sym
|
|
101
|
+
@expiry_time = opts[:expiry_time]
|
|
102
|
+
@caption = opts[:caption]
|
|
103
|
+
@raw = opts[:raw]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Is this a Notion-hosted (internal) file?
|
|
107
|
+
def internal? = @type == :file
|
|
108
|
+
|
|
109
|
+
# Is this an external URL?
|
|
110
|
+
def external? = @type == :external
|
|
111
|
+
|
|
112
|
+
# Has the Notion-hosted URL expired?
|
|
113
|
+
def expired?
|
|
114
|
+
return false unless internal? && @expiry_time
|
|
115
|
+
|
|
116
|
+
Time.parse(@expiry_time) < Time.now
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# ----------------------------------------------------------
|
|
120
|
+
# Serialisation: File → Notion API property value
|
|
121
|
+
# ----------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
# Single file → Notion file object (for use inside arrays)
|
|
124
|
+
def to_notion
|
|
125
|
+
case @type
|
|
126
|
+
when :external then to_notion_external
|
|
127
|
+
when :file then to_notion_file
|
|
128
|
+
else @raw
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Build Notion "files" property value from one or more Files
|
|
133
|
+
#
|
|
134
|
+
# File.to_notion_property([file1, file2])
|
|
135
|
+
# # => { "files" => [ { ... }, { ... } ] }
|
|
136
|
+
#
|
|
137
|
+
def self.to_notion_property(files)
|
|
138
|
+
files = Array(files)
|
|
139
|
+
{ "files" => files.map(&:to_notion) }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# ----------------------------------------------------------
|
|
143
|
+
# Convenience constructors
|
|
144
|
+
# ----------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
# Create an external file reference
|
|
147
|
+
#
|
|
148
|
+
# File.external("https://example.com/doc.pdf", name: "My Doc")
|
|
149
|
+
#
|
|
150
|
+
def self.external(url, name: nil)
|
|
151
|
+
new(url: url, type: :external, name: name)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Wrap multiple URLs into File instances
|
|
155
|
+
#
|
|
156
|
+
# File.from_urls("https://a.com/1.pdf", "https://b.com/2.pdf")
|
|
157
|
+
#
|
|
158
|
+
def self.from_urls(*urls)
|
|
159
|
+
urls.flatten.map { |u| external(u) }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# ----------------------------------------------------------
|
|
163
|
+
# Comparison & display
|
|
164
|
+
# ----------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
# raw tells apart files of an unknown type, which may have no URL
|
|
167
|
+
def ==(other)
|
|
168
|
+
other.is_a?(File) && @url == other.url &&
|
|
169
|
+
@type == other.type && @raw == other.raw
|
|
170
|
+
end
|
|
171
|
+
alias eql? ==
|
|
172
|
+
|
|
173
|
+
def hash
|
|
174
|
+
[@url, @type, @raw].hash
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def to_s
|
|
178
|
+
tag = expired? ? " [EXPIRED]" : ""
|
|
179
|
+
"#<#{self.class}:#{@type} #{@name || "unnamed"} → " \
|
|
180
|
+
"#{@url}#{tag}>"
|
|
181
|
+
end
|
|
182
|
+
alias inspect to_s
|
|
183
|
+
|
|
184
|
+
private
|
|
185
|
+
|
|
186
|
+
# to_notion's two branches, split out to keep to_notion itself
|
|
187
|
+
# short.
|
|
188
|
+
|
|
189
|
+
def to_notion_external
|
|
190
|
+
uri = parsed_url
|
|
191
|
+
{ "type" => "external", "name" => @name || default_name(uri),
|
|
192
|
+
"external" => { "url" => @url } }
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def parsed_url
|
|
196
|
+
URI.parse(@url)
|
|
197
|
+
rescue URI::InvalidURIError
|
|
198
|
+
raise Sequel::Error, "invalid file URL: #{@url.inspect}"
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# The URL's last path segment, decoded ("a%20b.pdf" is
|
|
202
|
+
# "a b.pdf"; one that does not decode stays as it is), or the
|
|
203
|
+
# URL when it has none
|
|
204
|
+
def default_name(uri)
|
|
205
|
+
base = ::File.basename(uri.path.to_s)
|
|
206
|
+
return @url if ["", "/"].include?(base)
|
|
207
|
+
|
|
208
|
+
name = URI.decode_uri_component(base)
|
|
209
|
+
name.valid_encoding? ? name : base
|
|
210
|
+
rescue ArgumentError
|
|
211
|
+
base
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def to_notion_file
|
|
215
|
+
# A Notion-hosted file is written back as read, signed URL
|
|
216
|
+
# and all, which Notion accepts and keeps the file for
|
|
217
|
+
# (checked live); a new one is uploaded with file_upload.
|
|
218
|
+
obj = {
|
|
219
|
+
"type" => "file",
|
|
220
|
+
"name" => @name || "",
|
|
221
|
+
"file" => { "url" => @url }
|
|
222
|
+
}
|
|
223
|
+
obj["file"]["expiry_time"] = @expiry_time if @expiry_time
|
|
224
|
+
obj
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sequel
|
|
4
|
+
module Notion
|
|
5
|
+
# The page and query endpoints the Dataset calls, over
|
|
6
|
+
# Database#request
|
|
7
|
+
module PageApi
|
|
8
|
+
def notion_query(data_source_id, body)
|
|
9
|
+
request(:post, "data_sources/#{data_source_id}/query", body)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def notion_get_page(page_id)
|
|
13
|
+
request(:get, "pages/#{page_id}")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Every item of a paginated page property (relation, people)
|
|
17
|
+
def notion_property_items(page_id, property_id)
|
|
18
|
+
path = "pages/#{page_id}/properties/#{property_id}"
|
|
19
|
+
items = []
|
|
20
|
+
cursor = nil
|
|
21
|
+
loop do
|
|
22
|
+
resp = request(:get, path,
|
|
23
|
+
cursor && { start_cursor: cursor })
|
|
24
|
+
items.concat(resp["results"] || [])
|
|
25
|
+
break items unless resp["has_more"]
|
|
26
|
+
|
|
27
|
+
cursor = resp["next_cursor"]
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def notion_create_page(data_source_id, properties)
|
|
32
|
+
request(:post, "pages",
|
|
33
|
+
parent: { type: "data_source_id",
|
|
34
|
+
data_source_id: data_source_id },
|
|
35
|
+
properties: properties)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def notion_update_page(page_id, properties, in_trash: nil)
|
|
39
|
+
body = { properties: properties }
|
|
40
|
+
body[:in_trash] = in_trash unless in_trash.nil?
|
|
41
|
+
request(:patch, "pages/#{page_id}", body)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def notion_trash_page(page_id)
|
|
45
|
+
request(:patch, "pages/#{page_id}", in_trash: true)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sequel/notion/discovery"
|
|
4
|
+
|
|
5
|
+
module Sequel
|
|
6
|
+
module Notion
|
|
7
|
+
# Table name => data source id, filled explicitly, by discovery, or
|
|
8
|
+
# lazily by searching for a data source of that name.
|
|
9
|
+
module Registry
|
|
10
|
+
include Discovery
|
|
11
|
+
|
|
12
|
+
UUID = /\A\h{8}-?\h{4}-?\h{4}-?\h{4}-?\h{12}\z/
|
|
13
|
+
|
|
14
|
+
# A discovered name that several data sources share: looking
|
|
15
|
+
# it up raises, until a registration picks one
|
|
16
|
+
Ambiguous = Data.define(:ids)
|
|
17
|
+
|
|
18
|
+
# The table name a data source title maps to: accents dropped
|
|
19
|
+
# from Latin letters only, letters of other scripts kept
|
|
20
|
+
def self.normalize(title)
|
|
21
|
+
title.to_s.unicode_normalize(:nfkd)
|
|
22
|
+
.gsub(/(?<=\p{Latin})\p{Mn}+/, "")
|
|
23
|
+
.unicode_normalize(:nfc)
|
|
24
|
+
.downcase
|
|
25
|
+
.gsub(/[^\p{L}\p{N}]+/, "_")
|
|
26
|
+
.gsub(/\A_|_\z/, "")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def register_data_source(name, data_source_id)
|
|
30
|
+
raise Error, "Missing data source id for #{name}" unless
|
|
31
|
+
data_source_id
|
|
32
|
+
|
|
33
|
+
# Notion answers 404 to an upper-case id
|
|
34
|
+
registry_store(name.to_sym, data_source_id.to_s.downcase)
|
|
35
|
+
self
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Register every data source found (in one database, or by a
|
|
39
|
+
# search); the block, given (title, id), may choose the name.
|
|
40
|
+
# All or nothing: a name clash registers none of them.
|
|
41
|
+
def register_all_data_sources(database: nil, query: nil, &mapper)
|
|
42
|
+
found = live(data_sources(database:, query:)).map do |ds|
|
|
43
|
+
[source_name(ds, mapper), ds[:id]]
|
|
44
|
+
end
|
|
45
|
+
Sequel.synchronize do
|
|
46
|
+
staged = registry.dup
|
|
47
|
+
found.each { |name, id| bind(staged, name, id) }
|
|
48
|
+
registry.replace(staged)
|
|
49
|
+
end
|
|
50
|
+
self
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def data_source_id_for(table_name)
|
|
54
|
+
name = table_name.to_sym
|
|
55
|
+
resolved(name, registry_fetch(name)) ||
|
|
56
|
+
(name.match?(UUID) && name.to_s.downcase) ||
|
|
57
|
+
(auto_register? && resolved(name, registry_fetch(name))) ||
|
|
58
|
+
search_data_source(name)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def tables
|
|
62
|
+
auto_register?
|
|
63
|
+
Sequel.synchronize do
|
|
64
|
+
registry.reject { |_, id| id.is_a?(Ambiguous) }.keys
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def registry = (@registry ||= {})
|
|
71
|
+
|
|
72
|
+
# A title with no letter or digit is named by its id
|
|
73
|
+
def source_name(source, mapper)
|
|
74
|
+
return mapper.call(source[:name], source[:id]).to_sym if mapper
|
|
75
|
+
|
|
76
|
+
name = Registry.normalize(source[:name])
|
|
77
|
+
(name.empty? ? source[:id] : name).to_sym
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def registry_fetch(name) = Sequel.synchronize { registry[name] }
|
|
81
|
+
|
|
82
|
+
def registry_store(name, id)
|
|
83
|
+
Sequel.synchronize { bind(registry, name, id) }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def resolved(name, id)
|
|
87
|
+
return id unless id.is_a?(Ambiguous)
|
|
88
|
+
|
|
89
|
+
raise Error, "data source name #{name} is ambiguous " \
|
|
90
|
+
"(#{id.ids.join(", ")}): register one"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# A registration may resolve an ambiguous name, never rebind
|
|
94
|
+
# one already bound
|
|
95
|
+
def bind(map, name, id)
|
|
96
|
+
old = map[name]
|
|
97
|
+
if old && old != id && !old.is_a?(Ambiguous)
|
|
98
|
+
raise Error, "data source name #{name} already maps " \
|
|
99
|
+
"to #{old}, not #{id}"
|
|
100
|
+
end
|
|
101
|
+
map[name] = id
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Discover every data source once, when auto_register is set;
|
|
105
|
+
# true when it is set. A name two sources share is marked
|
|
106
|
+
# ambiguous rather than failing the others, and a name already
|
|
107
|
+
# registered is kept. As Sequel does for its schema cache, the
|
|
108
|
+
# flag is set only once discovery has succeeded, and no lock is
|
|
109
|
+
# held across the requests: a concurrent lookup repeats the
|
|
110
|
+
# discovery rather than read a registry half filled, and a
|
|
111
|
+
# failed one is retried on the next lookup.
|
|
112
|
+
def auto_register?
|
|
113
|
+
return false unless opts[:auto_register]
|
|
114
|
+
|
|
115
|
+
unless Sequel.synchronize { @auto_registered }
|
|
116
|
+
discover_data_sources
|
|
117
|
+
Sequel.synchronize { @auto_registered = true }
|
|
118
|
+
end
|
|
119
|
+
true
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def discover_data_sources
|
|
123
|
+
found = live(data_sources).group_by { source_name(it, nil) }
|
|
124
|
+
Sequel.synchronize do
|
|
125
|
+
found.each { |name, sources| discovered(name, sources) }
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def discovered(name, sources)
|
|
130
|
+
return if registry[name] in String
|
|
131
|
+
|
|
132
|
+
ids = sources.map { it[:id] }.uniq
|
|
133
|
+
registry[name] = ids.one? ? ids.first : Ambiguous[ids]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Fallback: search by name, and remember what was found
|
|
137
|
+
def search_data_source(name)
|
|
138
|
+
ids = matching_source_ids(name)
|
|
139
|
+
return if ids.empty?
|
|
140
|
+
if ids.size > 1
|
|
141
|
+
raise Error, "data source name #{name} is ambiguous: " \
|
|
142
|
+
"#{ids.join(", ")}"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
registry_store(name, ids.first)
|
|
146
|
+
ids.first
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sequel
|
|
4
|
+
module Notion
|
|
5
|
+
# A ceiling on the Notion requests one query makes, mixed into
|
|
6
|
+
# Database. The budget covers every request answered 200 while
|
|
7
|
+
# the query runs, in this fiber, including the queries a join or
|
|
8
|
+
# a union runs inside it; the request past it raises before it is
|
|
9
|
+
# sent. A rate-limited attempt the retry absorbs, or a request
|
|
10
|
+
# that fails, spends nothing.
|
|
11
|
+
module RequestBudget
|
|
12
|
+
# Run the block under a budget of +max+ requests (nil: none).
|
|
13
|
+
# A query run inside a budgeted one spends the outer budget.
|
|
14
|
+
def with_request_budget(max)
|
|
15
|
+
return yield if max.nil? || Thread.current[budget_key]
|
|
16
|
+
|
|
17
|
+
begin
|
|
18
|
+
Thread.current[budget_key] = [max, 0]
|
|
19
|
+
yield
|
|
20
|
+
ensure
|
|
21
|
+
Thread.current[budget_key] = nil
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Run the block with no budget: what the caller does with a
|
|
26
|
+
# row (another query, say) is no part of the query that
|
|
27
|
+
# gave it
|
|
28
|
+
def outside_request_budget
|
|
29
|
+
saved = Thread.current[budget_key]
|
|
30
|
+
Thread.current[budget_key] = nil
|
|
31
|
+
yield
|
|
32
|
+
ensure
|
|
33
|
+
Thread.current[budget_key] = saved
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
# The block's answer, refused once the budget is spent and
|
|
39
|
+
# counted once it succeeds
|
|
40
|
+
def within_budget
|
|
41
|
+
check_request_budget!
|
|
42
|
+
yield.tap { spend_request! }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def budget_key = :"notion_budget_#{object_id}"
|
|
46
|
+
|
|
47
|
+
# Called before each request: refuse it once the budget is spent
|
|
48
|
+
def check_request_budget!
|
|
49
|
+
budget = Thread.current[budget_key] or return
|
|
50
|
+
return if budget.last < budget.first
|
|
51
|
+
|
|
52
|
+
raise Error, "query stopped after #{budget.first} Notion " \
|
|
53
|
+
"request(s), its max_requests; raise it in " \
|
|
54
|
+
"client_side(max_requests:)"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Called after each request answered 200
|
|
58
|
+
def spend_request!
|
|
59
|
+
budget = Thread.current[budget_key] or return
|
|
60
|
+
budget[1] += 1
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sequel/notion/type_map"
|
|
4
|
+
|
|
5
|
+
module Sequel
|
|
6
|
+
module Notion
|
|
7
|
+
module Schema
|
|
8
|
+
NOTION_TO_SEQUEL = {
|
|
9
|
+
"title" => :string,
|
|
10
|
+
"rich_text" => :string,
|
|
11
|
+
"number" => :float,
|
|
12
|
+
"select" => :string,
|
|
13
|
+
"multi_select" => :array,
|
|
14
|
+
"status" => :string,
|
|
15
|
+
"date" => :notion_date, # no Sequel typecast: kept as given
|
|
16
|
+
"checkbox" => :boolean,
|
|
17
|
+
"url" => :string,
|
|
18
|
+
"email" => :string,
|
|
19
|
+
"phone_number" => :string,
|
|
20
|
+
"people" => :array,
|
|
21
|
+
"relation" => :array,
|
|
22
|
+
"files" => :array,
|
|
23
|
+
"formula" => :string,
|
|
24
|
+
"created_time" => :string,
|
|
25
|
+
"last_edited_time" => :string
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
# Columns every row carries, besides the data source properties
|
|
29
|
+
PAGE_COLUMNS = [
|
|
30
|
+
[:id, { type: :string, db_type: "page_id",
|
|
31
|
+
primary_key: true, allow_null: false }],
|
|
32
|
+
[:in_trash, { type: :boolean, db_type: "in_trash",
|
|
33
|
+
allow_null: false }]
|
|
34
|
+
].freeze
|
|
35
|
+
|
|
36
|
+
# Rollup functions by the kind of value they give; any other
|
|
37
|
+
# (date_range, a new one) is unknown and not filterable
|
|
38
|
+
ROLLUP_KINDS = {
|
|
39
|
+
"array" => %w[show_original show_unique],
|
|
40
|
+
"date" => %w[earliest_date latest_date],
|
|
41
|
+
"number" => %w[count count_values empty not_empty unique
|
|
42
|
+
percent_empty percent_not_empty sum average
|
|
43
|
+
median min max range checked unchecked
|
|
44
|
+
percent_checked percent_unchecked
|
|
45
|
+
count_per_group percent_per_group]
|
|
46
|
+
}.freeze
|
|
47
|
+
|
|
48
|
+
module_function
|
|
49
|
+
|
|
50
|
+
def rollup_kind(prop)
|
|
51
|
+
function = prop.dig("rollup", "function")
|
|
52
|
+
ROLLUP_KINDS.find { |_, fns| fns.include?(function) }&.first
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Data source properties => Sequel schema rows
|
|
56
|
+
def notion_to_sequel(properties)
|
|
57
|
+
PAGE_COLUMNS.map { |name, info| [name, info.dup] } +
|
|
58
|
+
properties.map do |name, prop|
|
|
59
|
+
[name.to_sym, column(prop["type"])]
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def column(notion_type)
|
|
64
|
+
{ type: NOTION_TO_SEQUEL.fetch(notion_type, :string),
|
|
65
|
+
generated: TypeMap::READ_ONLY_TYPES.include?(notion_type),
|
|
66
|
+
db_type: notion_type,
|
|
67
|
+
notion_type: notion_type,
|
|
68
|
+
allow_null: true,
|
|
69
|
+
default: nil,
|
|
70
|
+
primary_key: false }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sequel/notion/schema"
|
|
4
|
+
|
|
5
|
+
module Sequel
|
|
6
|
+
module Notion
|
|
7
|
+
# A data source's properties, fetched once and cached, and what
|
|
8
|
+
# Sequel (columns) and the filter compiler (types, rollup kinds)
|
|
9
|
+
# read from them; mixed into Database
|
|
10
|
+
module SchemaLookup
|
|
11
|
+
# Sequel caches columns per table name, and another name for
|
|
12
|
+
# the data source (an alias, its id) would keep the old ones:
|
|
13
|
+
# all are dropped, rebuilt from the data source cache with no
|
|
14
|
+
# request
|
|
15
|
+
def refresh_schema!(table_name)
|
|
16
|
+
ds_id = data_source_id_for(table_name)
|
|
17
|
+
Sequel.synchronize do
|
|
18
|
+
@data_source_cache.delete(ds_id)
|
|
19
|
+
@data_source_epoch[ds_id] += 1
|
|
20
|
+
@schemas.clear
|
|
21
|
+
end
|
|
22
|
+
remove_cached_schema(table_name)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Property name => Notion type, for one data source
|
|
26
|
+
def property_type_map(ds_id)
|
|
27
|
+
data_source(ds_id).transform_values { it["type"] }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Rollup name => the kind of value it gives, for one data source
|
|
31
|
+
def rollup_kinds(ds_id)
|
|
32
|
+
data_source(ds_id).select { |_, p| p["type"] == "rollup" }
|
|
33
|
+
.transform_values { Schema.rollup_kind(it) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
# Data source object properties, fetched once and cached. A
|
|
39
|
+
# fetch that a refresh_schema! overtook fetches again, so its
|
|
40
|
+
# older answer never replaces the newer one
|
|
41
|
+
def data_source(ds_id)
|
|
42
|
+
loop do
|
|
43
|
+
epoch, cached = cached_properties(ds_id)
|
|
44
|
+
return cached if cached
|
|
45
|
+
|
|
46
|
+
props = fetch_properties(ds_id)
|
|
47
|
+
return props if store_properties(ds_id, epoch, props)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def cached_properties(ds_id)
|
|
52
|
+
Sequel.synchronize do
|
|
53
|
+
[@data_source_epoch[ds_id], @data_source_cache[ds_id]]
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Cache them, unless a refresh came in meanwhile
|
|
58
|
+
def store_properties(ds_id, epoch, props)
|
|
59
|
+
Sequel.synchronize do
|
|
60
|
+
next false unless @data_source_epoch[ds_id] == epoch
|
|
61
|
+
|
|
62
|
+
@data_source_cache[ds_id] = props
|
|
63
|
+
true
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def fetch_properties(ds_id)
|
|
68
|
+
props = request(:get,
|
|
69
|
+
"data_sources/#{ds_id}")["properties"] || {}
|
|
70
|
+
TypeMap.check_property_names!(props.keys)
|
|
71
|
+
props
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def schema_parse_table(table_name, _opts)
|
|
75
|
+
ds_id = data_source_id_for(table_name)
|
|
76
|
+
raise Error, "Unknown data source: #{table_name}" unless ds_id
|
|
77
|
+
|
|
78
|
+
Schema.notion_to_sequel(data_source(ds_id))
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|