chrono_model 1.0.1 → 1.1.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 +5 -5
- data/.travis.yml +19 -14
- data/README.md +49 -25
- data/lib/chrono_model.rb +37 -3
- data/lib/chrono_model/adapter.rb +91 -874
- data/lib/chrono_model/adapter/ddl.rb +225 -0
- data/lib/chrono_model/adapter/indexes.rb +194 -0
- data/lib/chrono_model/adapter/migrations.rb +282 -0
- data/lib/chrono_model/adapter/tsrange.rb +57 -0
- data/lib/chrono_model/adapter/upgrade.rb +120 -0
- data/lib/chrono_model/conversions.rb +20 -0
- data/lib/chrono_model/json.rb +28 -0
- data/lib/chrono_model/patches.rb +8 -232
- data/lib/chrono_model/patches/as_of_time_holder.rb +23 -0
- data/lib/chrono_model/patches/as_of_time_relation.rb +19 -0
- data/lib/chrono_model/patches/association.rb +52 -0
- data/lib/chrono_model/patches/db_console.rb +11 -0
- data/lib/chrono_model/patches/join_node.rb +32 -0
- data/lib/chrono_model/patches/preloader.rb +68 -0
- data/lib/chrono_model/patches/relation.rb +58 -0
- data/lib/chrono_model/time_gate.rb +5 -5
- data/lib/chrono_model/time_machine.rb +47 -427
- data/lib/chrono_model/time_machine/history_model.rb +196 -0
- data/lib/chrono_model/time_machine/time_query.rb +86 -0
- data/lib/chrono_model/time_machine/timeline.rb +94 -0
- data/lib/chrono_model/utilities.rb +27 -0
- data/lib/chrono_model/version.rb +1 -1
- data/spec/aruba/dbconsole_spec.rb +25 -0
- data/spec/chrono_model/adapter/counter_cache_race_spec.rb +46 -0
- data/spec/{adapter_spec.rb → chrono_model/adapter_spec.rb} +124 -5
- data/spec/{utils_spec.rb → chrono_model/conversions_spec.rb} +0 -0
- data/spec/{json_ops_spec.rb → chrono_model/json_ops_spec.rb} +11 -0
- data/spec/{time_machine_spec.rb → chrono_model/time_machine_spec.rb} +15 -5
- data/spec/{time_query_spec.rb → chrono_model/time_query_spec.rb} +0 -0
- data/spec/config.travis.yml +1 -0
- data/spec/config.yml.example +1 -0
- metadata +35 -14
- data/lib/chrono_model/utils.rb +0 -117
@@ -0,0 +1,57 @@
|
|
1
|
+
module ChronoModel
|
2
|
+
class Adapter < ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
|
3
|
+
|
4
|
+
module TSRange
|
5
|
+
# HACK: Redefine tsrange parsing support, as it is broken currently.
|
6
|
+
#
|
7
|
+
# This self-made API is here because currently AR4 does not support
|
8
|
+
# open-ended ranges. The reasons are poor support in Ruby:
|
9
|
+
#
|
10
|
+
# https://bugs.ruby-lang.org/issues/6864
|
11
|
+
#
|
12
|
+
# and an instable interface in Active Record:
|
13
|
+
#
|
14
|
+
# https://github.com/rails/rails/issues/13793
|
15
|
+
# https://github.com/rails/rails/issues/14010
|
16
|
+
#
|
17
|
+
# so, for now, we are implementing our own.
|
18
|
+
#
|
19
|
+
class Type < ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Range
|
20
|
+
OID = 3908
|
21
|
+
|
22
|
+
def cast_value(value)
|
23
|
+
return if value == 'empty'
|
24
|
+
return value if value.is_a?(::Array)
|
25
|
+
|
26
|
+
extracted = extract_bounds(value)
|
27
|
+
|
28
|
+
from = Conversions.string_to_utc_time extracted[:from]
|
29
|
+
to = Conversions.string_to_utc_time extracted[:to ]
|
30
|
+
|
31
|
+
[from, to]
|
32
|
+
end
|
33
|
+
|
34
|
+
def extract_bounds(value)
|
35
|
+
from, to = value[1..-2].split(',')
|
36
|
+
{
|
37
|
+
from: (value[1] == ',' || from == '-infinity') ? nil : from[1..-2],
|
38
|
+
to: (value[-2] == ',' || to == 'infinity') ? nil : to[1..-2],
|
39
|
+
}
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def initialize_type_map(m = type_map)
|
44
|
+
super.tap do
|
45
|
+
typ = ChronoModel::Adapter::TSRange::Type
|
46
|
+
oid = typ::OID
|
47
|
+
|
48
|
+
ar_type = type_map.fetch(oid)
|
49
|
+
cm_type = typ.new(ar_type.subtype, ar_type.type)
|
50
|
+
|
51
|
+
type_map.register_type oid, cm_type
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,120 @@
|
|
1
|
+
module ChronoModel
|
2
|
+
class Adapter < ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
|
3
|
+
|
4
|
+
module Upgrade
|
5
|
+
private
|
6
|
+
def chrono_upgrade!
|
7
|
+
chrono_ensure_schemas
|
8
|
+
|
9
|
+
chrono_upgrade_structure!
|
10
|
+
end
|
11
|
+
|
12
|
+
# Locate tables needing a structure upgrade
|
13
|
+
#
|
14
|
+
def chrono_tables_needing_upgrade
|
15
|
+
tables = { }
|
16
|
+
|
17
|
+
on_temporal_schema { self.tables }.each do |table_name|
|
18
|
+
next unless is_chrono?(table_name)
|
19
|
+
metadata = chrono_metadata_for(table_name)
|
20
|
+
version = metadata['chronomodel']
|
21
|
+
|
22
|
+
if version.blank?
|
23
|
+
tables[table_name] = { version: nil, priority: 'CRITICAL' }
|
24
|
+
elsif version != VERSION
|
25
|
+
tables[table_name] = { version: version, priority: 'LOW' }
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
return tables
|
30
|
+
end
|
31
|
+
|
32
|
+
# Emit a warning about tables needing an upgrade
|
33
|
+
#
|
34
|
+
def chrono_upgrade_warning
|
35
|
+
upgrade = chrono_tables_needing_upgrade.map do |table, desc|
|
36
|
+
"#{table} - priority: #{desc[:priority]}"
|
37
|
+
end.join('; ')
|
38
|
+
|
39
|
+
return if upgrade.empty?
|
40
|
+
|
41
|
+
logger.warn "ChronoModel: There are tables needing a structure upgrade, and ChronoModel structures need to be recreated."
|
42
|
+
logger.warn "ChronoModel: Please run ChronoModel.upgrade! to attempt the upgrade. If you have dependant database objects"
|
43
|
+
logger.warn "ChronoModel: the upgrade will fail and you have to drop the dependent objects, run .upgrade! and create them"
|
44
|
+
logger.warn "ChronoModel: again. Sorry. Some features or the whole library may not work correctly until upgrade is complete."
|
45
|
+
logger.warn "ChronoModel: Tables pending upgrade: #{upgrade}"
|
46
|
+
end
|
47
|
+
|
48
|
+
# Upgrades existing structure for each table, if required.
|
49
|
+
#
|
50
|
+
def chrono_upgrade_structure!
|
51
|
+
transaction do
|
52
|
+
|
53
|
+
chrono_tables_needing_upgrade.each do |table_name, desc|
|
54
|
+
|
55
|
+
if desc[:version].blank?
|
56
|
+
logger.info "ChronoModel: Upgrading legacy PG 9.0 table #{table_name} to #{VERSION}"
|
57
|
+
chrono_upgrade_from_postgres_9_0(table_name)
|
58
|
+
logger.info "ChronoModel: legacy #{table_name} upgrade complete"
|
59
|
+
else
|
60
|
+
logger.info "ChronoModel: upgrading #{table_name} from #{desc[:version]} to #{VERSION}"
|
61
|
+
chrono_create_view_for(table_name)
|
62
|
+
logger.info "ChronoModel: #{table_name} upgrade complete"
|
63
|
+
end
|
64
|
+
|
65
|
+
end
|
66
|
+
end
|
67
|
+
rescue => e
|
68
|
+
message = "ChronoModel structure upgrade failed: #{e.message}. Please drop dependent objects first and then run ChronoModel.upgrade! again."
|
69
|
+
|
70
|
+
# Quite important, output it also to stderr.
|
71
|
+
#
|
72
|
+
logger.error message
|
73
|
+
$stderr.puts message
|
74
|
+
end
|
75
|
+
|
76
|
+
def chrono_upgrade_from_postgres_9_0(table_name)
|
77
|
+
# roses are red
|
78
|
+
# violets are blue
|
79
|
+
# and this is the most boring piece of code ever
|
80
|
+
history_table = "#{HISTORY_SCHEMA}.#{table_name}"
|
81
|
+
p_pkey = primary_key(table_name)
|
82
|
+
|
83
|
+
execute "ALTER TABLE #{history_table} ADD COLUMN validity tsrange;"
|
84
|
+
execute """
|
85
|
+
UPDATE #{history_table} SET validity = tsrange(valid_from,
|
86
|
+
CASE WHEN extract(year from valid_to) = 9999 THEN NULL
|
87
|
+
ELSE valid_to
|
88
|
+
END
|
89
|
+
);
|
90
|
+
"""
|
91
|
+
|
92
|
+
execute "DROP INDEX #{history_table}_temporal_on_valid_from;"
|
93
|
+
execute "DROP INDEX #{history_table}_temporal_on_valid_from_and_valid_to;"
|
94
|
+
execute "DROP INDEX #{history_table}_temporal_on_valid_to;"
|
95
|
+
execute "DROP INDEX #{history_table}_inherit_pkey"
|
96
|
+
execute "DROP INDEX #{history_table}_recorded_at"
|
97
|
+
execute "DROP INDEX #{history_table}_instance_history"
|
98
|
+
execute "ALTER TABLE #{history_table} DROP CONSTRAINT #{table_name}_valid_from_before_valid_to;"
|
99
|
+
execute "ALTER TABLE #{history_table} DROP CONSTRAINT #{table_name}_timeline_consistency;"
|
100
|
+
execute "DROP RULE #{table_name}_upd_first ON #{table_name};"
|
101
|
+
execute "DROP RULE #{table_name}_upd_next ON #{table_name};"
|
102
|
+
execute "DROP RULE #{table_name}_del ON #{table_name};"
|
103
|
+
execute "DROP RULE #{table_name}_ins ON #{table_name};"
|
104
|
+
execute "DROP TRIGGER history_ins ON #{TEMPORAL_SCHEMA}.#{table_name};"
|
105
|
+
execute "DROP FUNCTION #{TEMPORAL_SCHEMA}.#{table_name}_ins();"
|
106
|
+
execute "ALTER TABLE #{history_table} DROP COLUMN valid_from;"
|
107
|
+
execute "ALTER TABLE #{history_table} DROP COLUMN valid_to;"
|
108
|
+
|
109
|
+
execute "CREATE EXTENSION IF NOT EXISTS btree_gist;"
|
110
|
+
|
111
|
+
chrono_create_view_for(table_name)
|
112
|
+
on_history_schema { add_history_validity_constraint(table_name, p_pkey) }
|
113
|
+
on_history_schema { chrono_create_history_indexes_for(table_name, p_pkey) }
|
114
|
+
end
|
115
|
+
# private
|
116
|
+
end
|
117
|
+
|
118
|
+
end
|
119
|
+
|
120
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
module ChronoModel
|
2
|
+
|
3
|
+
module Conversions
|
4
|
+
extend self
|
5
|
+
|
6
|
+
ISO_DATETIME = /\A(\d{4})-(\d\d)-(\d\d) (\d\d):(\d\d):(\d\d)(?:\.(\d+))?\z/
|
7
|
+
|
8
|
+
def string_to_utc_time(string)
|
9
|
+
if string =~ ISO_DATETIME
|
10
|
+
usec = $7.nil? ? '000000' : $7.ljust(6, '0') # .1 is .100000, not .000001
|
11
|
+
Time.utc $1.to_i, $2.to_i, $3.to_i, $4.to_i, $5.to_i, $6.to_i, usec.to_i
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
def time_to_utc_string(time)
|
16
|
+
[time.to_s(:db), sprintf('%06d', time.usec)].join '.'
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
module ChronoModel
|
2
|
+
|
3
|
+
module Json
|
4
|
+
extend self
|
5
|
+
|
6
|
+
def create
|
7
|
+
puts "ChronoModel: WARNING - JSON ops are deprecated. Please migrate to JSONB"
|
8
|
+
|
9
|
+
adapter.execute 'CREATE OR REPLACE LANGUAGE plpythonu'
|
10
|
+
adapter.execute File.read(sql 'json_ops.sql')
|
11
|
+
end
|
12
|
+
|
13
|
+
def drop
|
14
|
+
adapter.execute File.read(sql 'uninstall-json_ops.sql')
|
15
|
+
adapter.execute 'DROP LANGUAGE IF EXISTS plpythonu'
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
def sql(file)
|
20
|
+
File.dirname(__FILE__) + '/../../sql/' + file
|
21
|
+
end
|
22
|
+
|
23
|
+
def adapter
|
24
|
+
ActiveRecord::Base.connection
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
end
|
data/lib/chrono_model/patches.rb
CHANGED
@@ -1,232 +1,8 @@
|
|
1
|
-
require '
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
def as_of_time!(time)
|
10
|
-
@_as_of_time = time.utc
|
11
|
-
|
12
|
-
self
|
13
|
-
end
|
14
|
-
|
15
|
-
# Reads the virtual 'as_of_time' attribute
|
16
|
-
#
|
17
|
-
def as_of_time
|
18
|
-
@_as_of_time
|
19
|
-
end
|
20
|
-
end
|
21
|
-
|
22
|
-
# This class supports the AR 5.0 code that expects to receive an
|
23
|
-
# Arel::Table as the left join node. We need to replace the node
|
24
|
-
# with a virtual table that fetches from the history at a given
|
25
|
-
# point in time, we replace the join node with a SqlLiteral node
|
26
|
-
# that does not respond to the methods that AR expects.
|
27
|
-
#
|
28
|
-
# This class provides AR with an object implementing the methods
|
29
|
-
# it expects, yet producing SQL that fetches from history tables
|
30
|
-
# as-of-time.
|
31
|
-
#
|
32
|
-
class JoinNode < Arel::Nodes::SqlLiteral
|
33
|
-
attr_reader :name, :table_name, :table_alias, :as_of_time
|
34
|
-
|
35
|
-
def initialize(join_node, history_model, as_of_time)
|
36
|
-
@name = join_node.table_name
|
37
|
-
@table_name = join_node.table_name
|
38
|
-
@table_alias = join_node.table_alias
|
39
|
-
|
40
|
-
@as_of_time = as_of_time
|
41
|
-
|
42
|
-
virtual_table = history_model.
|
43
|
-
virtual_table_at(@as_of_time, @table_alias || @table_name)
|
44
|
-
|
45
|
-
super(virtual_table)
|
46
|
-
end
|
47
|
-
end
|
48
|
-
|
49
|
-
module Relation
|
50
|
-
include AsOfTimeHolder
|
51
|
-
|
52
|
-
def load
|
53
|
-
return super unless @_as_of_time && !loaded?
|
54
|
-
|
55
|
-
super.each {|record| record.as_of_time!(@_as_of_time) }
|
56
|
-
end
|
57
|
-
|
58
|
-
def merge(*)
|
59
|
-
return super unless @_as_of_time
|
60
|
-
|
61
|
-
super.as_of_time!(@_as_of_time)
|
62
|
-
end
|
63
|
-
|
64
|
-
def build_arel(*)
|
65
|
-
return super unless @_as_of_time
|
66
|
-
|
67
|
-
super.tap do |arel|
|
68
|
-
|
69
|
-
arel.join_sources.each do |join|
|
70
|
-
# This case happens with nested includes, where the below
|
71
|
-
# code has already replaced the join.left with a JoinNode.
|
72
|
-
#
|
73
|
-
next if join.left.respond_to?(:as_of_time)
|
74
|
-
|
75
|
-
model = TimeMachine.chrono_models[join.left.table_name]
|
76
|
-
next unless model
|
77
|
-
|
78
|
-
join.left = JoinNode.new(join.left, model.history, @_as_of_time)
|
79
|
-
end
|
80
|
-
|
81
|
-
end
|
82
|
-
end
|
83
|
-
|
84
|
-
# Build a preloader at the +as_of_time+ of this relation.
|
85
|
-
# Pass the current model to define Relation
|
86
|
-
#
|
87
|
-
def build_preloader
|
88
|
-
ActiveRecord::Associations::Preloader.new(
|
89
|
-
model: self.model, as_of_time: as_of_time
|
90
|
-
)
|
91
|
-
end
|
92
|
-
end
|
93
|
-
|
94
|
-
# This class is a dummy relation whose scope is only to pass around the
|
95
|
-
# as_of_time parameters across ActiveRecord call chains.
|
96
|
-
#
|
97
|
-
# With AR 5.2 a simple relation can be used, as the only required argument
|
98
|
-
# is the model. 5.0 and 5.1 require more arguments, that are passed here.
|
99
|
-
#
|
100
|
-
class AsOfTimeRelation < ActiveRecord::Relation
|
101
|
-
if ActiveRecord::VERSION::STRING.to_f < 5.2
|
102
|
-
def initialize(klass, table: klass.arel_table, predicate_builder: klass.predicate_builder, values: {})
|
103
|
-
super(klass, table, predicate_builder, values)
|
104
|
-
end
|
105
|
-
end
|
106
|
-
end
|
107
|
-
|
108
|
-
# Patches ActiveRecord::Associations::Preloader to add support for
|
109
|
-
# temporal associations. This is tying itself to Rails internals
|
110
|
-
# and it is ugly :-(.
|
111
|
-
#
|
112
|
-
module Preloader
|
113
|
-
attr_reader :options
|
114
|
-
|
115
|
-
# We overwrite the initializer in order to pass the +as_of_time+
|
116
|
-
# parameter above in the build_preloader
|
117
|
-
#
|
118
|
-
def initialize(options = {})
|
119
|
-
@options = options.freeze
|
120
|
-
end
|
121
|
-
|
122
|
-
# Patches the AR Preloader (lib/active_record/associations/preloader.rb)
|
123
|
-
# in order to carry around the +as_of_time+ of the original invocation.
|
124
|
-
#
|
125
|
-
# * The +records+ are the parent records where the association is defined
|
126
|
-
# * The +associations+ are the association names involved in preloading
|
127
|
-
# * The +given_preload_scope+ is the preloading scope, that is used only
|
128
|
-
# in the :through association and it holds the intermediate records
|
129
|
-
# _through_ which the final associated records are eventually fetched.
|
130
|
-
#
|
131
|
-
# As the +preload_scope+ is passed around to all the different
|
132
|
-
# incarnations of the preloader strategies, we are using it to pass
|
133
|
-
# around the +as_of_time+ of the original query invocation, so that
|
134
|
-
# preloaded records are preloaded honoring the +as_of_time+.
|
135
|
-
#
|
136
|
-
# The +preload_scope+ is present only in through associations, but the
|
137
|
-
# preloader interfaces expect it to be always defined, for consistency.
|
138
|
-
#
|
139
|
-
# For `:through` associations, the +given_preload_scope+ is already a
|
140
|
-
# +Relation+, that already has the +as_of_time+ getters and setters,
|
141
|
-
# so we use it directly.
|
142
|
-
#
|
143
|
-
def preload(records, associations, given_preload_scope = nil)
|
144
|
-
if options[:as_of_time]
|
145
|
-
preload_scope = given_preload_scope ||
|
146
|
-
AsOfTimeRelation.new(options[:model])
|
147
|
-
|
148
|
-
preload_scope.as_of_time!(options[:as_of_time])
|
149
|
-
end
|
150
|
-
|
151
|
-
super records, associations, preload_scope
|
152
|
-
end
|
153
|
-
|
154
|
-
module Association
|
155
|
-
# Builds the preloader scope taking into account a potential
|
156
|
-
# +as_of_time+ passed down the call chain starting at the
|
157
|
-
# end user invocation.
|
158
|
-
#
|
159
|
-
def build_scope
|
160
|
-
scope = super
|
161
|
-
|
162
|
-
if preload_scope.try(:as_of_time)
|
163
|
-
scope = scope.as_of(preload_scope.as_of_time)
|
164
|
-
end
|
165
|
-
|
166
|
-
return scope
|
167
|
-
end
|
168
|
-
end
|
169
|
-
end
|
170
|
-
|
171
|
-
# Patches ActiveRecord::Associations::Association to add support for
|
172
|
-
# temporal associations.
|
173
|
-
#
|
174
|
-
# Each record fetched from the +as_of+ scope on the owner class will have
|
175
|
-
# an additional "as_of_time" field yielding the UTC time of the request,
|
176
|
-
# then the as_of scope is called on either this association's class or
|
177
|
-
# on the join model's (:through association) one.
|
178
|
-
#
|
179
|
-
module Association
|
180
|
-
def skip_statement_cache?(*)
|
181
|
-
super || _chrono_target?
|
182
|
-
end
|
183
|
-
|
184
|
-
# If the association class or the through association are ChronoModels,
|
185
|
-
# then fetches the records from a virtual table using a subquery scope
|
186
|
-
# to a specific timestamp.
|
187
|
-
def scope
|
188
|
-
scope = super
|
189
|
-
return scope unless _chrono_record?
|
190
|
-
|
191
|
-
if _chrono_target?
|
192
|
-
# For standard associations, replace the table name with the virtual
|
193
|
-
# as-of table name at the owner's as-of-time
|
194
|
-
#
|
195
|
-
scope = scope.from(klass.history.virtual_table_at(owner.as_of_time))
|
196
|
-
elsif respond_to?(:through_reflection) && through_reflection.klass.chrono?
|
197
|
-
|
198
|
-
# For through associations, replace the joined table name instead
|
199
|
-
# with a virtual table that selects records from the history at
|
200
|
-
# the given +as_of_time+.
|
201
|
-
#
|
202
|
-
scope.join_sources.each do |join|
|
203
|
-
if join.left.name == through_reflection.klass.table_name
|
204
|
-
history_model = through_reflection.klass.history
|
205
|
-
|
206
|
-
join.left = JoinNode.new(join.left, history_model, owner.as_of_time)
|
207
|
-
end
|
208
|
-
end
|
209
|
-
end
|
210
|
-
|
211
|
-
scope.as_of_time!(owner.as_of_time)
|
212
|
-
|
213
|
-
return scope
|
214
|
-
end
|
215
|
-
|
216
|
-
private
|
217
|
-
def _chrono_record?
|
218
|
-
owner.respond_to?(:as_of_time) && owner.as_of_time.present?
|
219
|
-
end
|
220
|
-
|
221
|
-
def _chrono_target?
|
222
|
-
@_target_klass ||= reflection.options[:polymorphic] ?
|
223
|
-
owner.public_send(reflection.foreign_type).constantize :
|
224
|
-
reflection.klass
|
225
|
-
|
226
|
-
@_target_klass.chrono?
|
227
|
-
end
|
228
|
-
|
229
|
-
end
|
230
|
-
|
231
|
-
end
|
232
|
-
end
|
1
|
+
require 'chrono_model/patches/as_of_time_holder'
|
2
|
+
require 'chrono_model/patches/as_of_time_relation'
|
3
|
+
|
4
|
+
require 'chrono_model/patches/join_node'
|
5
|
+
require 'chrono_model/patches/relation'
|
6
|
+
require 'chrono_model/patches/preloader'
|
7
|
+
require 'chrono_model/patches/association'
|
8
|
+
require 'chrono_model/patches/db_console'
|