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.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +207 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +470 -0
  5. data/lib/sequel/adapters/notion.rb +159 -0
  6. data/lib/sequel/notion/dataset.rb +167 -0
  7. data/lib/sequel/notion/dataset_aggregates.rb +82 -0
  8. data/lib/sequel/notion/dataset_compounds.rb +89 -0
  9. data/lib/sequel/notion/dataset_computed.rb +124 -0
  10. data/lib/sequel/notion/dataset_grouping.rb +145 -0
  11. data/lib/sequel/notion/dataset_having.rb +99 -0
  12. data/lib/sequel/notion/dataset_joins.rb +124 -0
  13. data/lib/sequel/notion/dataset_pages.rb +145 -0
  14. data/lib/sequel/notion/dataset_selection.rb +51 -0
  15. data/lib/sequel/notion/dataset_truncation.rb +39 -0
  16. data/lib/sequel/notion/discovery.rb +76 -0
  17. data/lib/sequel/notion/errors.rb +9 -0
  18. data/lib/sequel/notion/filter_comparison.rb +123 -0
  19. data/lib/sequel/notion/filter_compiler.rb +121 -0
  20. data/lib/sequel/notion/filter_constants.rb +37 -0
  21. data/lib/sequel/notion/filter_helpers.rb +162 -0
  22. data/lib/sequel/notion/filter_like.rb +143 -0
  23. data/lib/sequel/notion/filter_like_tokenizer.rb +42 -0
  24. data/lib/sequel/notion/filter_negation.rb +76 -0
  25. data/lib/sequel/notion/filter_nested.rb +96 -0
  26. data/lib/sequel/notion/filter_nulls.rb +53 -0
  27. data/lib/sequel/notion/filter_predicates.rb +160 -0
  28. data/lib/sequel/notion/filter_shape.rb +75 -0
  29. data/lib/sequel/notion/filter_tables.rb +111 -0
  30. data/lib/sequel/notion/group_accumulator.rb +55 -0
  31. data/lib/sequel/notion/join_output.rb +58 -0
  32. data/lib/sequel/notion/join_where.rb +111 -0
  33. data/lib/sequel/notion/model_support.rb +52 -0
  34. data/lib/sequel/notion/notion_file.rb +228 -0
  35. data/lib/sequel/notion/page_api.rb +49 -0
  36. data/lib/sequel/notion/registry.rb +150 -0
  37. data/lib/sequel/notion/request_budget.rb +64 -0
  38. data/lib/sequel/notion/schema.rb +74 -0
  39. data/lib/sequel/notion/schema_lookup.rb +82 -0
  40. data/lib/sequel/notion/sort_compiler.rb +63 -0
  41. data/lib/sequel/notion/type_map.rb +125 -0
  42. data/lib/sequel/notion/type_map_builders.rb +125 -0
  43. data/lib/sequel/notion/type_map_dates.rb +67 -0
  44. data/lib/sequel/notion/type_map_extractors.rb +89 -0
  45. data/lib/sequel/notion/version.rb +7 -0
  46. data/lib/sequel/notion.rb +5 -0
  47. 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