sequel 5.86.0 → 5.88.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 +4 -4
- data/lib/sequel/adapters/shared/mysql.rb +5 -1
- data/lib/sequel/adapters/shared/postgres.rb +20 -0
- data/lib/sequel/adapters/trilogy.rb +1 -2
- data/lib/sequel/database/misc.rb +6 -2
- data/lib/sequel/dataset/query.rb +7 -3
- data/lib/sequel/dataset/sql.rb +6 -1
- data/lib/sequel/extensions/migration.rb +19 -3
- data/lib/sequel/extensions/null_dataset.rb +2 -2
- data/lib/sequel/extensions/pg_auto_parameterize.rb +1 -1
- data/lib/sequel/extensions/pg_schema_caching.rb +90 -0
- data/lib/sequel/extensions/schema_caching.rb +24 -9
- data/lib/sequel/extensions/sqlite_json_ops.rb +1 -1
- data/lib/sequel/extensions/string_agg.rb +2 -2
- data/lib/sequel/model/base.rb +31 -13
- data/lib/sequel/plugins/inspect_pk.rb +44 -0
- data/lib/sequel/plugins/serialization.rb +10 -4
- data/lib/sequel/plugins/static_cache_cache.rb +50 -13
- data/lib/sequel/plugins/subset_static_cache.rb +262 -0
- data/lib/sequel/version.rb +1 -1
- metadata +6 -6
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 6a4a563fddfd5332195e8b9ba2588aef535f27ea0cee15f43afdc877910775ba
|
4
|
+
data.tar.gz: 47cb743d96f031e4fa7e415708473158b2f564b3faa155335a4787e79bce34bd
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: b3dd656f73cdf525bda28a2cb5f136f91ae2935f48cfd94f8dc06f042b7f682c2faf48a48625b7845ae058b92189447433c152ab11eeebb8baae925d5371326d
|
7
|
+
data.tar.gz: 84ae987e64b8c872351d259d88a1e8b63b7926e06f9bda7a0d71dda4826e4e17c83fdc5538bcde63cc0d16f77ffd75c57b504947a692898d552a77e1814a7c44
|
@@ -567,7 +567,7 @@ module Sequel
|
|
567
567
|
im = input_identifier_meth(opts[:dataset])
|
568
568
|
table = SQL::Identifier.new(im.call(table_name))
|
569
569
|
table = SQL::QualifiedIdentifier.new(im.call(opts[:schema]), table) if opts[:schema]
|
570
|
-
metadata_dataset.with_sql("
|
570
|
+
metadata_dataset.with_sql("SHOW FULL COLUMNS FROM ?", table).map do |row|
|
571
571
|
extra = row.delete(:Extra)
|
572
572
|
if row[:primary_key] = row.delete(:Key) == 'PRI'
|
573
573
|
row[:auto_increment] = !!(extra.to_s =~ /auto_increment/i)
|
@@ -577,10 +577,14 @@ module Sequel
|
|
577
577
|
row[:generated] = !!(extra.to_s =~ /VIRTUAL|STORED|PERSISTENT/i)
|
578
578
|
end
|
579
579
|
row[:allow_null] = row.delete(:Null) == 'YES'
|
580
|
+
row[:comment] = row.delete(:Comment)
|
581
|
+
row[:comment] = nil if row[:comment] == ""
|
580
582
|
row[:default] = row.delete(:Default)
|
581
583
|
row[:db_type] = row.delete(:Type)
|
582
584
|
row[:type] = schema_column_type(row[:db_type])
|
583
585
|
row[:extra] = extra
|
586
|
+
row.delete(:Collation)
|
587
|
+
row.delete(:Privileges)
|
584
588
|
[m.call(row.delete(:Field)), row]
|
585
589
|
end
|
586
590
|
end
|
@@ -1075,6 +1075,7 @@ module Sequel
|
|
1075
1075
|
pg_attribute[:attname].as(:name),
|
1076
1076
|
SQL::Cast.new(pg_attribute[:atttypid], :integer).as(:oid),
|
1077
1077
|
SQL::Cast.new(basetype[:oid], :integer).as(:base_oid),
|
1078
|
+
SQL::Function.new(:col_description, pg_class[:oid], pg_attribute[:attnum]).as(:comment),
|
1078
1079
|
SQL::Function.new(:format_type, basetype[:oid], pg_type[:typtypmod]).as(:db_base_type),
|
1079
1080
|
SQL::Function.new(:format_type, pg_type[:oid], pg_attribute[:atttypmod]).as(:db_type),
|
1080
1081
|
SQL::Function.new(:pg_get_expr, pg_attrdef[:adbin], pg_class[:oid]).as(:default),
|
@@ -2387,6 +2388,25 @@ module Sequel
|
|
2387
2388
|
join_from_sql(:USING, sql)
|
2388
2389
|
end
|
2389
2390
|
|
2391
|
+
# Handle column aliases containing data types, useful for selecting from functions
|
2392
|
+
# that return the record data type.
|
2393
|
+
def derived_column_list_sql_append(sql, column_aliases)
|
2394
|
+
c = false
|
2395
|
+
comma = ', '
|
2396
|
+
column_aliases.each do |a|
|
2397
|
+
sql << comma if c
|
2398
|
+
if a.is_a?(Array)
|
2399
|
+
raise Error, "column aliases specified as arrays must have only 2 elements, the first is alias name and the second is data type" unless a.length == 2
|
2400
|
+
a, type = a
|
2401
|
+
identifier_append(sql, a)
|
2402
|
+
sql << " " << db.cast_type_literal(type).to_s
|
2403
|
+
else
|
2404
|
+
identifier_append(sql, a)
|
2405
|
+
end
|
2406
|
+
c ||= true
|
2407
|
+
end
|
2408
|
+
end
|
2409
|
+
|
2390
2410
|
# Add ON CONFLICT clause if it should be used
|
2391
2411
|
def insert_conflict_sql(sql)
|
2392
2412
|
if opts = @opts[:insert_conflict]
|
data/lib/sequel/database/misc.rb
CHANGED
@@ -246,9 +246,13 @@ module Sequel
|
|
246
246
|
# extension does not have specific support for Database objects, an Error will be raised.
|
247
247
|
# Returns self.
|
248
248
|
def extension(*exts)
|
249
|
-
Sequel.extension(*exts)
|
250
249
|
exts.each do |ext|
|
251
|
-
|
250
|
+
unless pr = Sequel.synchronize{EXTENSIONS[ext]}
|
251
|
+
Sequel.extension(ext)
|
252
|
+
pr = Sequel.synchronize{EXTENSIONS[ext]}
|
253
|
+
end
|
254
|
+
|
255
|
+
if pr
|
252
256
|
if Sequel.synchronize{@loaded_extensions.include?(ext) ? false : (@loaded_extensions << ext)}
|
253
257
|
pr.call(self)
|
254
258
|
end
|
data/lib/sequel/dataset/query.rb
CHANGED
@@ -204,7 +204,7 @@ module Sequel
|
|
204
204
|
# If no related extension file exists or the extension does not have
|
205
205
|
# specific support for Dataset objects, an error will be raised.
|
206
206
|
def extension(*exts)
|
207
|
-
Sequel.extension(
|
207
|
+
exts.each{|ext| Sequel.extension(ext) unless Sequel.synchronize{EXTENSIONS[ext]}}
|
208
208
|
mods = exts.map{|ext| Sequel.synchronize{EXTENSION_MODULES[ext]}}
|
209
209
|
if mods.all?
|
210
210
|
with_extend(*mods)
|
@@ -1359,9 +1359,13 @@ module Sequel
|
|
1359
1359
|
unless TRUE_FREEZE
|
1360
1360
|
# Load the extensions into the receiver, without checking if the receiver is frozen.
|
1361
1361
|
def _extension!(exts)
|
1362
|
-
Sequel.extension(*exts)
|
1363
1362
|
exts.each do |ext|
|
1364
|
-
|
1363
|
+
unless pr = Sequel.synchronize{EXTENSIONS[ext]}
|
1364
|
+
Sequel.extension(ext)
|
1365
|
+
pr = Sequel.synchronize{EXTENSIONS[ext]}
|
1366
|
+
end
|
1367
|
+
|
1368
|
+
if pr
|
1365
1369
|
pr.call(self)
|
1366
1370
|
else
|
1367
1371
|
raise(Error, "Extension #{ext} does not have specific support handling individual datasets (try: Sequel.extension #{ext.inspect})")
|
data/lib/sequel/dataset/sql.rb
CHANGED
@@ -1032,7 +1032,7 @@ module Sequel
|
|
1032
1032
|
if column_aliases
|
1033
1033
|
raise Error, "#{db.database_type} does not support derived column lists" unless supports_derived_column_lists?
|
1034
1034
|
sql << '('
|
1035
|
-
|
1035
|
+
derived_column_list_sql_append(sql, column_aliases)
|
1036
1036
|
sql << ')'
|
1037
1037
|
end
|
1038
1038
|
end
|
@@ -1165,6 +1165,11 @@ module Sequel
|
|
1165
1165
|
end
|
1166
1166
|
end
|
1167
1167
|
|
1168
|
+
# Append the column aliases to the SQL.
|
1169
|
+
def derived_column_list_sql_append(sql, column_aliases)
|
1170
|
+
identifier_list_append(sql, column_aliases)
|
1171
|
+
end
|
1172
|
+
|
1168
1173
|
# Disable caching of SQL for the current dataset
|
1169
1174
|
def disable_sql_caching!
|
1170
1175
|
cache_set(:_no_cache_sql, true)
|
@@ -223,7 +223,7 @@ module Sequel
|
|
223
223
|
@actions << [:drop_join_table, *args]
|
224
224
|
end
|
225
225
|
|
226
|
-
def create_table(name, opts=OPTS)
|
226
|
+
def create_table(name, opts=OPTS, &_)
|
227
227
|
@actions << [:drop_table, name, opts]
|
228
228
|
end
|
229
229
|
|
@@ -371,7 +371,7 @@ module Sequel
|
|
371
371
|
#
|
372
372
|
# Part of the +migration+ extension.
|
373
373
|
class Migrator
|
374
|
-
MIGRATION_FILE_PATTERN = /\A(\d+)_
|
374
|
+
MIGRATION_FILE_PATTERN = /\A(\d+)_(.+)\.rb\z/i.freeze
|
375
375
|
|
376
376
|
# Mutex used around migration file loading
|
377
377
|
MUTEX = Mutex.new
|
@@ -791,7 +791,23 @@ module Sequel
|
|
791
791
|
next unless MIGRATION_FILE_PATTERN.match(file)
|
792
792
|
files << File.join(directory, file)
|
793
793
|
end
|
794
|
-
files.
|
794
|
+
files.sort! do |a, b|
|
795
|
+
a_ver, a_name = split_migration_filename(a)
|
796
|
+
b_ver, b_name = split_migration_filename(b)
|
797
|
+
x = a_ver <=> b_ver
|
798
|
+
if x.zero?
|
799
|
+
x = a_name <=> b_name
|
800
|
+
end
|
801
|
+
x
|
802
|
+
end
|
803
|
+
files
|
804
|
+
end
|
805
|
+
|
806
|
+
# Return an integer and name (without extension) for the given path.
|
807
|
+
def split_migration_filename(path)
|
808
|
+
version, name = MIGRATION_FILE_PATTERN.match(File.basename(path)).captures
|
809
|
+
version = version.to_i
|
810
|
+
[version, name]
|
795
811
|
end
|
796
812
|
|
797
813
|
# Returns tuples of migration, filename, and direction
|
@@ -63,12 +63,12 @@ module Sequel
|
|
63
63
|
end
|
64
64
|
|
65
65
|
# Return self without sending a database query, never yielding.
|
66
|
-
def each
|
66
|
+
def each(&_)
|
67
67
|
self
|
68
68
|
end
|
69
69
|
|
70
70
|
# Return nil without sending a database query, never yielding.
|
71
|
-
def fetch_rows(sql)
|
71
|
+
def fetch_rows(sql, &_)
|
72
72
|
nil
|
73
73
|
end
|
74
74
|
|
@@ -394,7 +394,7 @@ module Sequel
|
|
394
394
|
# there can be more than one parameter per column, so this doesn't prevent going
|
395
395
|
# over the limit, though it does make it less likely.
|
396
396
|
def default_import_slice
|
397
|
-
40
|
397
|
+
@opts[:no_auto_parameterize] ? super : 40
|
398
398
|
end
|
399
399
|
|
400
400
|
# Handle parameterization of multi_insert_sql
|
@@ -0,0 +1,90 @@
|
|
1
|
+
# frozen-string-literal: true
|
2
|
+
#
|
3
|
+
# The pg_schema_caching extension builds on top of the schema_caching
|
4
|
+
# extension, and allows it to handle custom PostgreSQL types. On
|
5
|
+
# PostgreSQL, column schema hashes include an :oid entry for the OID
|
6
|
+
# for the column's type. For custom types, this OID is dependent on
|
7
|
+
# the PostgreSQL database, so in most cases, test and development
|
8
|
+
# versions of the same database, created with the same migrations,
|
9
|
+
# will have different OIDs.
|
10
|
+
#
|
11
|
+
# To fix this case, the pg_schema_caching extension removes custom
|
12
|
+
# OIDs from the schema cache when dumping the schema, replacing them
|
13
|
+
# with a placeholder. When loading the cached schema, the Database
|
14
|
+
# object makes a single query to get the OIDs for all custom types
|
15
|
+
# used by the cached schema, and it updates all related column
|
16
|
+
# schema hashes to set the correct :oid entry for the current
|
17
|
+
# database.
|
18
|
+
#
|
19
|
+
# Related module: Sequel::Postgres::SchemaCaching
|
20
|
+
|
21
|
+
require_relative "schema_caching"
|
22
|
+
|
23
|
+
module Sequel
|
24
|
+
module Postgres
|
25
|
+
module SchemaCaching
|
26
|
+
include Sequel::SchemaCaching
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
# Load custom oids from database when loading schema cache file.
|
31
|
+
def load_schema_cache_file(file)
|
32
|
+
set_custom_oids_for_cached_schema(super)
|
33
|
+
end
|
34
|
+
|
35
|
+
# Find all column schema hashes that use custom types.
|
36
|
+
# Load the oids for custom types in a single query, and update
|
37
|
+
# each related column schema hash with the correct oid.
|
38
|
+
def set_custom_oids_for_cached_schema(schemas)
|
39
|
+
custom_oid_rows = {}
|
40
|
+
|
41
|
+
schemas.each_value do |cols|
|
42
|
+
cols.each do |_, h|
|
43
|
+
if h[:oid] == :custom
|
44
|
+
(custom_oid_rows[h[:db_type]] ||= []) << h
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
unless custom_oid_rows.empty?
|
50
|
+
from(:pg_type).where(:typname=>custom_oid_rows.keys).select_hash(:typname, :oid).each do |name, oid|
|
51
|
+
custom_oid_rows.delete(name).each do |row|
|
52
|
+
row[:oid] = oid
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
unless custom_oid_rows.empty?
|
58
|
+
warn "Could not load OIDs for the following custom types: #{custom_oid_rows.keys.sort.join(", ")}", uplevel: 3
|
59
|
+
|
60
|
+
schemas.keys.each do |k|
|
61
|
+
if schemas[k].any?{|_,h| h[:oid] == :custom}
|
62
|
+
# Remove schema entry for table, so it will be queried at runtime to get the correct oids
|
63
|
+
schemas.delete(k)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
schemas
|
69
|
+
end
|
70
|
+
|
71
|
+
# Replace :oid entries for custom types with :custom.
|
72
|
+
def dumpable_schema_cache
|
73
|
+
sch = super
|
74
|
+
|
75
|
+
sch.each_value do |cols|
|
76
|
+
cols.each do |_, h|
|
77
|
+
if (oid = h[:oid]) && oid >= 10000
|
78
|
+
h[:oid] = :custom
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
sch
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
Database.register_extension(:pg_schema_caching, Postgres::SchemaCaching)
|
89
|
+
end
|
90
|
+
|
@@ -51,14 +51,7 @@ module Sequel
|
|
51
51
|
module SchemaCaching
|
52
52
|
# Dump the cached schema to the filename given in Marshal format.
|
53
53
|
def dump_schema_cache(file)
|
54
|
-
sch =
|
55
|
-
@schemas.sort.each do |k,v|
|
56
|
-
sch[k] = v.map do |c, h|
|
57
|
-
h = Hash[h]
|
58
|
-
h.delete(:callable_default)
|
59
|
-
[c, h]
|
60
|
-
end
|
61
|
-
end
|
54
|
+
sch = dumpable_schema_cache
|
62
55
|
File.open(file, 'wb'){|f| f.write(Marshal.dump(sch))}
|
63
56
|
nil
|
64
57
|
end
|
@@ -72,7 +65,7 @@ module Sequel
|
|
72
65
|
# Replace the schema cache with the data from the given file, which
|
73
66
|
# should be in Marshal format.
|
74
67
|
def load_schema_cache(file)
|
75
|
-
@schemas =
|
68
|
+
@schemas = load_schema_cache_file(file)
|
76
69
|
@schemas.each_value{|v| schema_post_process(v)}
|
77
70
|
nil
|
78
71
|
end
|
@@ -82,6 +75,28 @@ module Sequel
|
|
82
75
|
def load_schema_cache?(file)
|
83
76
|
load_schema_cache(file) if File.exist?(file)
|
84
77
|
end
|
78
|
+
|
79
|
+
private
|
80
|
+
|
81
|
+
# Return the deserialized schema cache file.
|
82
|
+
def load_schema_cache_file(file)
|
83
|
+
Marshal.load(File.read(file))
|
84
|
+
end
|
85
|
+
|
86
|
+
# A dumpable version of the schema cache.
|
87
|
+
def dumpable_schema_cache
|
88
|
+
sch = {}
|
89
|
+
|
90
|
+
@schemas.sort.each do |k,v|
|
91
|
+
sch[k] = v.map do |c, h|
|
92
|
+
h = Hash[h]
|
93
|
+
h.delete(:callable_default)
|
94
|
+
[c, h]
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
sch
|
99
|
+
end
|
85
100
|
end
|
86
101
|
|
87
102
|
Database.register_extension(:schema_caching, SchemaCaching)
|
@@ -35,7 +35,7 @@
|
|
35
35
|
#
|
36
36
|
# j[1] # (json_column ->> 1)
|
37
37
|
# j.get(1) # (json_column ->> 1)
|
38
|
-
# j.
|
38
|
+
# j.get_json(1) # (json_column -> 1)
|
39
39
|
# j.extract('$.a') # json_extract(json_column, '$.a')
|
40
40
|
# jb.extract('$.a') # jsonb_extract(jsonb_column, '$.a')
|
41
41
|
#
|
@@ -173,7 +173,7 @@ module Sequel
|
|
173
173
|
# Return a modified StringAgg that uses distinct expressions
|
174
174
|
def distinct
|
175
175
|
self.class.new(@expr, @separator) do |sa|
|
176
|
-
sa.instance_variable_set(:@order_expr, @order_expr)
|
176
|
+
sa.instance_variable_set(:@order_expr, @order_expr)
|
177
177
|
sa.instance_variable_set(:@distinct, true)
|
178
178
|
end
|
179
179
|
end
|
@@ -181,8 +181,8 @@ module Sequel
|
|
181
181
|
# Return a modified StringAgg with the given order
|
182
182
|
def order(*o)
|
183
183
|
self.class.new(@expr, @separator) do |sa|
|
184
|
-
sa.instance_variable_set(:@distinct, @distinct) if @distinct
|
185
184
|
sa.instance_variable_set(:@order_expr, o.empty? ? nil : o.freeze)
|
185
|
+
sa.instance_variable_set(:@distinct, @distinct)
|
186
186
|
end
|
187
187
|
end
|
188
188
|
|
data/lib/sequel/model/base.rb
CHANGED
@@ -87,7 +87,7 @@ module Sequel
|
|
87
87
|
attr_reader :simple_pk
|
88
88
|
|
89
89
|
# Should be the literal table name if this Model's dataset is a simple table (no select, order, join, etc.),
|
90
|
-
# or nil otherwise. This and simple_pk are used for an optimization in Model
|
90
|
+
# or nil otherwise. This and simple_pk are used for an optimization in Model[].
|
91
91
|
attr_reader :simple_table
|
92
92
|
|
93
93
|
# Whether mass assigning via .create/.new/#set/#update should raise an error
|
@@ -398,7 +398,7 @@ module Sequel
|
|
398
398
|
end
|
399
399
|
|
400
400
|
# Finds a single record according to the supplied filter.
|
401
|
-
# You are encouraged to use Model
|
401
|
+
# You are encouraged to use Model[] or Model.first instead of this method.
|
402
402
|
#
|
403
403
|
# Artist.find(name: 'Bob')
|
404
404
|
# # SELECT * FROM artists WHERE (name = 'Bob') LIMIT 1
|
@@ -762,22 +762,35 @@ module Sequel
|
|
762
762
|
end
|
763
763
|
end
|
764
764
|
end
|
765
|
+
|
766
|
+
# Module that the class methods that call dataset methods are kept in.
|
767
|
+
# This allows the methods to be overridden and call super with the
|
768
|
+
# default behavior.
|
769
|
+
def dataset_methods_module
|
770
|
+
return @dataset_methods_module if defined?(@dataset_methods_module)
|
771
|
+
Sequel.synchronize{@dataset_methods_module ||= Module.new}
|
772
|
+
extend(@dataset_methods_module)
|
773
|
+
@dataset_methods_module
|
774
|
+
end
|
765
775
|
|
766
|
-
# Define a model method that calls the dataset method with the same name
|
767
|
-
# only used for methods with names that can't be represented directly in
|
768
|
-
# ruby code.
|
776
|
+
# Define a model method that calls the dataset method with the same name.
|
769
777
|
def def_model_dataset_method(meth)
|
770
778
|
return if respond_to?(meth, true)
|
771
779
|
|
780
|
+
mod = dataset_methods_module
|
781
|
+
|
772
782
|
if meth.to_s =~ /\A[A-Za-z_][A-Za-z0-9_]*\z/
|
773
|
-
|
783
|
+
mod.module_eval(<<END, __FILE__, __LINE__ + 1)
|
784
|
+
def #{meth}(*args, &block); dataset.#{meth}(*args, &block) end
|
785
|
+
ruby2_keywords :#{meth} if respond_to?(:ruby2_keywords, true)
|
786
|
+
END
|
774
787
|
else
|
775
|
-
|
788
|
+
mod.send(:define_method, meth){|*args, &block| dataset.public_send(meth, *args, &block)}
|
789
|
+
# :nocov:
|
790
|
+
mod.send(:ruby2_keywords, meth) if respond_to?(:ruby2_keywords, true)
|
791
|
+
# :nocov:
|
776
792
|
end
|
777
|
-
|
778
|
-
# :nocov:
|
779
|
-
singleton_class.send(:ruby2_keywords, meth) if respond_to?(:ruby2_keywords, true)
|
780
|
-
# :nocov:
|
793
|
+
mod.send(:alias_method, meth, meth)
|
781
794
|
end
|
782
795
|
|
783
796
|
# Get the schema from the database, fall back on checking the columns
|
@@ -1311,7 +1324,7 @@ module Sequel
|
|
1311
1324
|
# Returns a string representation of the model instance including
|
1312
1325
|
# the class name and values.
|
1313
1326
|
def inspect
|
1314
|
-
"#<#{
|
1327
|
+
"#<#{inspect_prefix} @values=#{inspect_values}>"
|
1315
1328
|
end
|
1316
1329
|
|
1317
1330
|
# Returns the keys in +values+. May not include all column names.
|
@@ -1994,7 +2007,12 @@ module Sequel
|
|
1994
2007
|
set(h) unless h.empty?
|
1995
2008
|
end
|
1996
2009
|
|
1997
|
-
# Default
|
2010
|
+
# Default inspect output for the inspect, by default, just showing the class.
|
2011
|
+
def inspect_prefix
|
2012
|
+
model.name
|
2013
|
+
end
|
2014
|
+
|
2015
|
+
# Default inspect output for the values hash, overwrite to change what #inspect displays.
|
1998
2016
|
def inspect_values
|
1999
2017
|
@values.inspect
|
2000
2018
|
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen-string-literal: true
|
2
|
+
|
3
|
+
module Sequel
|
4
|
+
module Plugins
|
5
|
+
# The inspect_pk plugin includes the pk right next to the
|
6
|
+
# model name in inspect, allowing for easily copying and
|
7
|
+
# pasting to retrieve a copy of the object:
|
8
|
+
#
|
9
|
+
# Album.with_pk(1).inspect
|
10
|
+
# # default: #<Album @values={...}>
|
11
|
+
# # with inspect_pk: #<Album[1] @values={...}>
|
12
|
+
#
|
13
|
+
# Usage:
|
14
|
+
#
|
15
|
+
# # Make all model instances include pk in inspect output
|
16
|
+
# Sequel::Model.plugin :inspect_pk
|
17
|
+
#
|
18
|
+
# # Make Album instances include pk in inspect output
|
19
|
+
# Album.plugin :inspect_pk
|
20
|
+
module InspectPk
|
21
|
+
module InstanceMethods
|
22
|
+
private
|
23
|
+
|
24
|
+
# The primary key value to include in the inspect output, if any.
|
25
|
+
# For composite primary keys, this only includes a value if all
|
26
|
+
# fields are present.
|
27
|
+
def inspect_pk
|
28
|
+
if primary_key && (pk = self.pk) && (!(Array === pk) || pk.all?)
|
29
|
+
pk
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
# Include the instance's primary key in the output.
|
34
|
+
def inspect_prefix
|
35
|
+
if v = inspect_pk
|
36
|
+
"#{super}[#{v.inspect}]"
|
37
|
+
else
|
38
|
+
super
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -37,7 +37,8 @@ module Sequel
|
|
37
37
|
#
|
38
38
|
# # Register custom serializer/deserializer pair, if desired
|
39
39
|
# require 'sequel/plugins/serialization'
|
40
|
-
#
|
40
|
+
# require 'base64'
|
41
|
+
# Sequel::Plugins::Serialization.register_format(:base64, Base64.method(:encode64), Base64.method(:decode64))
|
41
42
|
#
|
42
43
|
# class User < Sequel::Model
|
43
44
|
# # Built-in format support when loading the plugin
|
@@ -48,10 +49,10 @@ module Sequel
|
|
48
49
|
# serialize_attributes :marshal, :permissions
|
49
50
|
#
|
50
51
|
# # Use custom registered serialization format just like built-in format
|
51
|
-
# serialize_attributes :
|
52
|
+
# serialize_attributes :base64, :password
|
52
53
|
#
|
53
54
|
# # Use a custom serializer/deserializer pair without registering
|
54
|
-
# serialize_attributes [:
|
55
|
+
# serialize_attributes [ Base64.method(:encode64), Base64.method(:decode64)], :password
|
55
56
|
# end
|
56
57
|
# user = User.create
|
57
58
|
# user.permissions = {global: 'read-only'}
|
@@ -123,7 +124,12 @@ module Sequel
|
|
123
124
|
end
|
124
125
|
|
125
126
|
# Create instance level reader that deserializes column values on request,
|
126
|
-
# and instance level writer that stores new deserialized values.
|
127
|
+
# and instance level writer that stores new deserialized values. If +format+
|
128
|
+
# is a symbol, it should correspond to a previously-registered format using +register_format+.
|
129
|
+
# Otherwise, +format+ is expected to be a 2-element array of callables,
|
130
|
+
# with the first element being the serializer, used to convert the value used by the application
|
131
|
+
# to the value that will be stored in the database, and the second element being the deserializer,
|
132
|
+
# used to convert the value stored the database to the value used by the application.
|
127
133
|
def serialize_attributes(format, *columns)
|
128
134
|
if format.is_a?(Symbol)
|
129
135
|
unless format = Sequel.synchronize{REGISTERED_FORMATS[format]}
|
@@ -2,11 +2,11 @@
|
|
2
2
|
|
3
3
|
module Sequel
|
4
4
|
module Plugins
|
5
|
-
# The static_cache_cache plugin allows for caching the row content for
|
6
|
-
# that use the
|
7
|
-
# can avoid the need to query the database every time loading
|
8
|
-
#
|
9
|
-
# plugin.
|
5
|
+
# The static_cache_cache plugin allows for caching the row content for the current
|
6
|
+
# class and subclasses that use the static_cache or subset_static_cache plugins.
|
7
|
+
# Using this plugin can avoid the need to query the database every time loading
|
8
|
+
# the static_cache plugin into a model (static_cache plugin) or using the
|
9
|
+
# cache_subset method (subset_static_cache plugin).
|
10
10
|
#
|
11
11
|
# Usage:
|
12
12
|
#
|
@@ -26,11 +26,7 @@ module Sequel
|
|
26
26
|
module ClassMethods
|
27
27
|
# Dump the in-memory cached rows to the cache file.
|
28
28
|
def dump_static_cache_cache
|
29
|
-
|
30
|
-
@static_cache_cache.sort.each do |k, v|
|
31
|
-
static_cache_cache[k] = v
|
32
|
-
end
|
33
|
-
File.open(@static_cache_cache_file, 'wb'){|f| f.write(Marshal.dump(static_cache_cache))}
|
29
|
+
File.open(@static_cache_cache_file, 'wb'){|f| f.write(Marshal.dump(sort_static_cache_hash(@static_cache_cache)))}
|
34
30
|
nil
|
35
31
|
end
|
36
32
|
|
@@ -38,16 +34,57 @@ module Sequel
|
|
38
34
|
|
39
35
|
private
|
40
36
|
|
37
|
+
# Sort the given static cache hash in a deterministic way, so that
|
38
|
+
# the same static cache values will result in the same marshal file.
|
39
|
+
def sort_static_cache_hash(cache)
|
40
|
+
cache = cache.sort do |a, b|
|
41
|
+
a, = a
|
42
|
+
b, = b
|
43
|
+
if a.is_a?(Array)
|
44
|
+
if b.is_a?(Array)
|
45
|
+
a_name, a_meth = a
|
46
|
+
b_name, b_meth = b
|
47
|
+
x = a_name <=> b_name
|
48
|
+
if x.zero?
|
49
|
+
x = a_meth <=> b_meth
|
50
|
+
end
|
51
|
+
x
|
52
|
+
else
|
53
|
+
1
|
54
|
+
end
|
55
|
+
elsif b.is_a?(Array)
|
56
|
+
-1
|
57
|
+
else
|
58
|
+
a <=> b
|
59
|
+
end
|
60
|
+
end
|
61
|
+
Hash[cache]
|
62
|
+
end
|
63
|
+
|
41
64
|
# Load the rows for the model from the cache if available.
|
42
65
|
# If not available, load the rows from the database, and
|
43
66
|
# then update the cache with the raw rows.
|
44
67
|
def load_static_cache_rows
|
45
|
-
|
68
|
+
_load_static_cache_rows(dataset, name)
|
69
|
+
end
|
70
|
+
|
71
|
+
# Load the rows for the subset from the cache if available.
|
72
|
+
# If not available, load the rows from the database, and
|
73
|
+
# then update the cache with the raw rows.
|
74
|
+
def load_subset_static_cache_rows(ds, meth)
|
75
|
+
_load_static_cache_rows(ds, [name, meth].freeze)
|
76
|
+
end
|
77
|
+
|
78
|
+
# Check the cache first for the key, and return rows without a database
|
79
|
+
# query if present. Otherwise, get all records in the provided dataset,
|
80
|
+
# and update the cache with them.
|
81
|
+
def _load_static_cache_rows(ds, key)
|
82
|
+
if rows = Sequel.synchronize{@static_cache_cache[key]}
|
46
83
|
rows.map{|row| call(row)}.freeze
|
47
84
|
else
|
48
|
-
rows =
|
85
|
+
rows = ds.all.freeze
|
49
86
|
raw_rows = rows.map(&:values)
|
50
|
-
Sequel.synchronize{@static_cache_cache[
|
87
|
+
Sequel.synchronize{@static_cache_cache[key] = raw_rows}
|
51
88
|
rows
|
52
89
|
end
|
53
90
|
end
|
@@ -0,0 +1,262 @@
|
|
1
|
+
# frozen-string-literal: true
|
2
|
+
|
3
|
+
module Sequel
|
4
|
+
module Plugins
|
5
|
+
# The subset_static_cache plugin is designed for model subsets that are not modified at all
|
6
|
+
# in production use cases, or at least where modifications to them would usually
|
7
|
+
# coincide with an application restart. When caching a model subset, it
|
8
|
+
# retrieves all rows in the database and statically caches a ruby array and hash
|
9
|
+
# keyed on primary key containing all of the model instances. All of these cached
|
10
|
+
# instances are frozen so they won't be modified unexpectedly.
|
11
|
+
#
|
12
|
+
# With the following code:
|
13
|
+
#
|
14
|
+
# class StatusType < Sequel::Model
|
15
|
+
# dataset_module do
|
16
|
+
# where :available, hidden: false
|
17
|
+
# end
|
18
|
+
# cache_subset :available
|
19
|
+
# end
|
20
|
+
#
|
21
|
+
# The following methods will use the cache and not issue a database query:
|
22
|
+
#
|
23
|
+
# * StatusType.available.with_pk
|
24
|
+
# * StatusType.available.all
|
25
|
+
# * StatusType.available.each
|
26
|
+
# * StatusType.available.first (without block, only supporting no arguments or single integer argument)
|
27
|
+
# * StatusType.available.count (without an argument or block)
|
28
|
+
# * StatusType.available.map
|
29
|
+
# * StatusType.available.as_hash
|
30
|
+
# * StatusType.available.to_hash
|
31
|
+
# * StatusType.available.to_hash_groups
|
32
|
+
#
|
33
|
+
# The cache is not used if you chain methods before or after calling the cached
|
34
|
+
# method, as doing so would not be safe:
|
35
|
+
#
|
36
|
+
# StatusType.where{number > 1}.available.all
|
37
|
+
# StatusType.available.where{number > 1}.all
|
38
|
+
#
|
39
|
+
# The cache is also not used if you change the class's dataset after caching
|
40
|
+
# the subset, or in subclasses of the model.
|
41
|
+
#
|
42
|
+
# You should not modify any row that is statically cached when using this plugin,
|
43
|
+
# as otherwise you will get different results for cached and uncached method
|
44
|
+
# calls.
|
45
|
+
module SubsetStaticCache
|
46
|
+
def self.configure(model)
|
47
|
+
model.class_exec do
|
48
|
+
@subset_static_caches ||= ({}.compare_by_identity)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
module ClassMethods
|
53
|
+
# Cache the given subset statically, so that calling the subset method on
|
54
|
+
# the model will return a dataset that will return cached results instead
|
55
|
+
# of issuing database queries (assuming the cache has the necessary
|
56
|
+
# information).
|
57
|
+
#
|
58
|
+
# The model must already respond to the given method before cache_subset
|
59
|
+
# is called.
|
60
|
+
def cache_subset(meth)
|
61
|
+
ds = send(meth).with_extend(CachedDatasetMethods)
|
62
|
+
cache = ds.instance_variable_get(:@cache)
|
63
|
+
|
64
|
+
rows, hash = subset_static_cache_rows(ds, meth)
|
65
|
+
cache[:subset_static_cache_all] = rows
|
66
|
+
cache[:subset_static_cache_map] = hash
|
67
|
+
|
68
|
+
caches = @subset_static_caches
|
69
|
+
caches[meth] = ds
|
70
|
+
model = self
|
71
|
+
subset_static_cache_module.send(:define_method, meth) do
|
72
|
+
if (model == self) && (cached_dataset = caches[meth])
|
73
|
+
cached_dataset
|
74
|
+
else
|
75
|
+
super()
|
76
|
+
end
|
77
|
+
end
|
78
|
+
nil
|
79
|
+
end
|
80
|
+
|
81
|
+
Plugins.after_set_dataset(self, :clear_subset_static_caches)
|
82
|
+
Plugins.inherited_instance_variables(self, :@subset_static_caches=>proc{{}.compare_by_identity})
|
83
|
+
|
84
|
+
private
|
85
|
+
|
86
|
+
# Clear the subset_static_caches. This is used if the model dataset
|
87
|
+
# changes, to prevent cached values from being used.
|
88
|
+
def clear_subset_static_caches
|
89
|
+
@subset_static_caches.clear
|
90
|
+
end
|
91
|
+
|
92
|
+
# A module for the subset static cache methods, so that you can define
|
93
|
+
# a singleton method in the class with the same name, and call super
|
94
|
+
# to get default behavior.
|
95
|
+
def subset_static_cache_module
|
96
|
+
return @subset_static_cache_module if @subset_static_cache_module
|
97
|
+
|
98
|
+
# Ensure dataset_methods module is defined and class is extended with
|
99
|
+
# it before calling creating this module.
|
100
|
+
dataset_methods_module
|
101
|
+
|
102
|
+
Sequel.synchronize{@subset_static_cache_module ||= Module.new}
|
103
|
+
extend(@subset_static_cache_module)
|
104
|
+
@subset_static_cache_module
|
105
|
+
end
|
106
|
+
|
107
|
+
# Return the frozen array and hash used for caching the subset
|
108
|
+
# of the given dataset.
|
109
|
+
def subset_static_cache_rows(ds, meth)
|
110
|
+
all = load_subset_static_cache_rows(ds, meth)
|
111
|
+
h = {}
|
112
|
+
all.each do |o|
|
113
|
+
o.errors.freeze
|
114
|
+
h[o.pk.freeze] = o.freeze
|
115
|
+
end
|
116
|
+
[all, h.freeze]
|
117
|
+
end
|
118
|
+
|
119
|
+
# Return a frozen array for all rows in the dataset.
|
120
|
+
def load_subset_static_cache_rows(ds, meth)
|
121
|
+
ret = super if defined?(super)
|
122
|
+
ret || ds.all.freeze
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
module CachedDatasetMethods
|
127
|
+
# An array of all of the dataset's instances, without issuing a database
|
128
|
+
# query. If a block is given, yields each instance to the block.
|
129
|
+
def all(&block)
|
130
|
+
return super unless all = @cache[:subset_static_cache_all]
|
131
|
+
|
132
|
+
array = all.dup
|
133
|
+
array.each(&block) if block
|
134
|
+
array
|
135
|
+
end
|
136
|
+
|
137
|
+
# Get the number of records in the cache, without issuing a database query,
|
138
|
+
# if no arguments or block are provided.
|
139
|
+
def count(*a, &block)
|
140
|
+
if a.empty? && !block && (all = @cache[:subset_static_cache_all])
|
141
|
+
all.size
|
142
|
+
else
|
143
|
+
super
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
# If a block is given, multiple arguments are given, or a single
|
148
|
+
# non-Integer argument is given, performs the default behavior of
|
149
|
+
# issuing a database query. Otherwise, uses the cached values
|
150
|
+
# to return either the first cached instance (no arguments) or an
|
151
|
+
# array containing the number of instances specified (single integer
|
152
|
+
# argument).
|
153
|
+
def first(*args)
|
154
|
+
if !defined?(yield) && args.length <= 1 && (args.length == 0 || args[0].is_a?(Integer)) && (all = @cache[:subset_static_cache_all])
|
155
|
+
all.first(*args)
|
156
|
+
else
|
157
|
+
super
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
# Return the frozen object with the given pk, or nil if no such object exists
|
162
|
+
# in the cache, without issuing a database query.
|
163
|
+
def with_pk(pk)
|
164
|
+
if cache = @cache[:subset_static_cache_map]
|
165
|
+
cache[pk]
|
166
|
+
else
|
167
|
+
super
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
# Yield each of the dataset's frozen instances to the block, without issuing a database
|
172
|
+
# query.
|
173
|
+
def each(&block)
|
174
|
+
return super unless all = @cache[:subset_static_cache_all]
|
175
|
+
all.each(&block)
|
176
|
+
end
|
177
|
+
|
178
|
+
# Use the cache instead of a query to get the results.
|
179
|
+
def map(column=nil, &block)
|
180
|
+
return super unless all = @cache[:subset_static_cache_all]
|
181
|
+
if column
|
182
|
+
raise(Error, "Cannot provide both column and block to map") if block
|
183
|
+
if column.is_a?(Array)
|
184
|
+
all.map{|r| r.values.values_at(*column)}
|
185
|
+
else
|
186
|
+
all.map{|r| r[column]}
|
187
|
+
end
|
188
|
+
else
|
189
|
+
all.map(&block)
|
190
|
+
end
|
191
|
+
end
|
192
|
+
|
193
|
+
# Use the cache instead of a query to get the results if possible
|
194
|
+
def as_hash(key_column = nil, value_column = nil, opts = OPTS)
|
195
|
+
return super unless all = @cache[:subset_static_cache_all]
|
196
|
+
|
197
|
+
if key_column.nil? && value_column.nil?
|
198
|
+
if opts[:hash]
|
199
|
+
key_column = model.primary_key
|
200
|
+
else
|
201
|
+
return Hash[@cache[:subset_static_cache_map]]
|
202
|
+
end
|
203
|
+
end
|
204
|
+
|
205
|
+
h = opts[:hash] || {}
|
206
|
+
if value_column
|
207
|
+
if value_column.is_a?(Array)
|
208
|
+
if key_column.is_a?(Array)
|
209
|
+
all.each{|r| h[r.values.values_at(*key_column)] = r.values.values_at(*value_column)}
|
210
|
+
else
|
211
|
+
all.each{|r| h[r[key_column]] = r.values.values_at(*value_column)}
|
212
|
+
end
|
213
|
+
else
|
214
|
+
if key_column.is_a?(Array)
|
215
|
+
all.each{|r| h[r.values.values_at(*key_column)] = r[value_column]}
|
216
|
+
else
|
217
|
+
all.each{|r| h[r[key_column]] = r[value_column]}
|
218
|
+
end
|
219
|
+
end
|
220
|
+
elsif key_column.is_a?(Array)
|
221
|
+
all.each{|r| h[r.values.values_at(*key_column)] = r}
|
222
|
+
else
|
223
|
+
all.each{|r| h[r[key_column]] = r}
|
224
|
+
end
|
225
|
+
h
|
226
|
+
end
|
227
|
+
|
228
|
+
# Alias of as_hash for backwards compatibility.
|
229
|
+
def to_hash(*a)
|
230
|
+
as_hash(*a)
|
231
|
+
end
|
232
|
+
|
233
|
+
# Use the cache instead of a query to get the results
|
234
|
+
def to_hash_groups(key_column, value_column = nil, opts = OPTS)
|
235
|
+
return super unless all = @cache[:subset_static_cache_all]
|
236
|
+
|
237
|
+
h = opts[:hash] || {}
|
238
|
+
if value_column
|
239
|
+
if value_column.is_a?(Array)
|
240
|
+
if key_column.is_a?(Array)
|
241
|
+
all.each{|r| (h[r.values.values_at(*key_column)] ||= []) << r.values.values_at(*value_column)}
|
242
|
+
else
|
243
|
+
all.each{|r| (h[r[key_column]] ||= []) << r.values.values_at(*value_column)}
|
244
|
+
end
|
245
|
+
else
|
246
|
+
if key_column.is_a?(Array)
|
247
|
+
all.each{|r| (h[r.values.values_at(*key_column)] ||= []) << r[value_column]}
|
248
|
+
else
|
249
|
+
all.each{|r| (h[r[key_column]] ||= []) << r[value_column]}
|
250
|
+
end
|
251
|
+
end
|
252
|
+
elsif key_column.is_a?(Array)
|
253
|
+
all.each{|r| (h[r.values.values_at(*key_column)] ||= []) << r}
|
254
|
+
else
|
255
|
+
all.each{|r| (h[r[key_column]] ||= []) << r}
|
256
|
+
end
|
257
|
+
h
|
258
|
+
end
|
259
|
+
end
|
260
|
+
end
|
261
|
+
end
|
262
|
+
end
|
data/lib/sequel/version.rb
CHANGED
@@ -6,7 +6,7 @@ module Sequel
|
|
6
6
|
|
7
7
|
# The minor version of Sequel. Bumped for every non-patch level
|
8
8
|
# release, generally around once a month.
|
9
|
-
MINOR =
|
9
|
+
MINOR = 88
|
10
10
|
|
11
11
|
# The tiny version of Sequel. Usually 0, only bumped for bugfix
|
12
12
|
# releases that fix regressions from previous versions.
|
metadata
CHANGED
@@ -1,14 +1,13 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: sequel
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 5.
|
4
|
+
version: 5.88.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Jeremy Evans
|
8
|
-
autorequire:
|
9
8
|
bindir: bin
|
10
9
|
cert_chain: []
|
11
|
-
date:
|
10
|
+
date: 2025-01-01 00:00:00.000000000 Z
|
12
11
|
dependencies:
|
13
12
|
- !ruby/object:Gem::Dependency
|
14
13
|
name: bigdecimal
|
@@ -268,6 +267,7 @@ files:
|
|
268
267
|
- lib/sequel/extensions/pg_range_ops.rb
|
269
268
|
- lib/sequel/extensions/pg_row.rb
|
270
269
|
- lib/sequel/extensions/pg_row_ops.rb
|
270
|
+
- lib/sequel/extensions/pg_schema_caching.rb
|
271
271
|
- lib/sequel/extensions/pg_static_cache_updater.rb
|
272
272
|
- lib/sequel/extensions/pg_timestamptz.rb
|
273
273
|
- lib/sequel/extensions/pretty_table.rb
|
@@ -353,6 +353,7 @@ files:
|
|
353
353
|
- lib/sequel/plugins/input_transformer.rb
|
354
354
|
- lib/sequel/plugins/insert_conflict.rb
|
355
355
|
- lib/sequel/plugins/insert_returning_select.rb
|
356
|
+
- lib/sequel/plugins/inspect_pk.rb
|
356
357
|
- lib/sequel/plugins/instance_filters.rb
|
357
358
|
- lib/sequel/plugins/instance_hooks.rb
|
358
359
|
- lib/sequel/plugins/instance_specific_default.rb
|
@@ -390,6 +391,7 @@ files:
|
|
390
391
|
- lib/sequel/plugins/string_stripper.rb
|
391
392
|
- lib/sequel/plugins/subclasses.rb
|
392
393
|
- lib/sequel/plugins/subset_conditions.rb
|
394
|
+
- lib/sequel/plugins/subset_static_cache.rb
|
393
395
|
- lib/sequel/plugins/table_select.rb
|
394
396
|
- lib/sequel/plugins/tactical_eager_loading.rb
|
395
397
|
- lib/sequel/plugins/throw_failures.rb
|
@@ -422,7 +424,6 @@ metadata:
|
|
422
424
|
documentation_uri: https://sequel.jeremyevans.net/documentation.html
|
423
425
|
mailing_list_uri: https://github.com/jeremyevans/sequel/discussions
|
424
426
|
source_code_uri: https://github.com/jeremyevans/sequel
|
425
|
-
post_install_message:
|
426
427
|
rdoc_options:
|
427
428
|
- "--quiet"
|
428
429
|
- "--line-numbers"
|
@@ -444,8 +445,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
444
445
|
- !ruby/object:Gem::Version
|
445
446
|
version: '0'
|
446
447
|
requirements: []
|
447
|
-
rubygems_version: 3.
|
448
|
-
signing_key:
|
448
|
+
rubygems_version: 3.6.2
|
449
449
|
specification_version: 4
|
450
450
|
summary: The Database Toolkit for Ruby
|
451
451
|
test_files: []
|