omen 0.3.1 → 0.5.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.
data/lib/omen/grants.rb CHANGED
@@ -3,6 +3,12 @@ module Omen
3
3
  # may read, and what it may not. Kept apart from the task that runs them, which is about a
4
4
  # database it has to find and a refusal it has to survive rather than about privileges.
5
5
  module Grants
6
+ # Membership without inheritance, so a role that enters a narrowed one with SET LOCAL ROLE
7
+ # is not itself held back by its policies. Postgres 16 and later; below that the grant is
8
+ # refused and the task says so, which leaves a reading misconfigured rather than narrowing
9
+ # somebody nobody meant to narrow.
10
+ APART = 'GRANT %{role} TO %{member} WITH INHERIT FALSE'
11
+
6
12
  # DDL, which Active Record has no expression for, and not a query.
7
13
  # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
8
14
  # @param members [Array<String>] the roles that may SET LOCAL ROLE to this one. The owner
@@ -12,20 +18,66 @@ module Omen
12
18
  def self.statements(connection, members)
13
19
  role = connection.quote_table_name Omen.config.narrow_role
14
20
  [
15
- 'DO $$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = ' \
16
- "#{connection.quote Omen.config.narrow_role}) THEN CREATE ROLE #{role} NOLOGIN; " \
17
- 'END IF; END $$',
18
- "ALTER ROLE #{role} WITH #{Attributes::SETTABLE}",
21
+ *made(connection, Omen.config.narrow_role),
22
+ *members.map { |member| "GRANT #{role} TO #{connection.quote_table_name member}" },
19
23
  "GRANT USAGE ON SCHEMA public TO #{role}",
20
24
  "GRANT SELECT ON ALL TABLES IN SCHEMA public TO #{role}",
21
25
  "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO #{role}",
22
- *members.map { |member| "GRANT #{role} TO #{connection.quote_table_name member}" },
23
26
  *revoked(connection, role),
24
- *Omen::Eastern.statements(connection),
27
+ *Omen::Renamed.statements,
28
+ *Omen::TimeZone.statements(connection),
25
29
  *Omen::Distance.statements(connection),
26
30
  ]
27
31
  end
28
32
 
33
+ # The same role, made for one audience rather than for every table: it is granted nothing
34
+ # here, since a narrowing names the tables and the columns of each itself.
35
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
36
+ # @param name [String] the role to make.
37
+ # @param members [Array<String>] the roles that may enter it, none of them inheriting it.
38
+ # @return [Array<String>] the statements to run, in order.
39
+ def self.narrowed(connection, name, members)
40
+ role = connection.quote_table_name name
41
+ [
42
+ *made(connection, name),
43
+ *members.map { |member| apart role, connection.quote_table_name(member) },
44
+ "GRANT USAGE ON SCHEMA public TO #{role}",
45
+ ]
46
+ end
47
+
48
+ # @param role [String] the role, quoted.
49
+ # @param member [String] the role that may enter it, quoted.
50
+ # @return [String] the statement that lets it in without handing it what the role holds.
51
+ def self.apart(role, member) = APART % { role: role, member: member }
52
+
53
+ # Named one by one rather than granted whole: a column added to hold a secret, or to count
54
+ # what belongs to everybody, is one a role has to be given before it can read it. Taken away
55
+ # first, since a grant only ever adds: without the revoke, a column dropped from the list
56
+ # stays readable by whoever was granted it the last time this ran.
57
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
58
+ # @param name [String] the role to grant them to.
59
+ # @param table [String] the table whose columns are being granted.
60
+ # @param refused [Regexp] the columns of it that role may not read.
61
+ # @return [Array<String>] what it may read of that table, said over again.
62
+ def self.granted(connection, name, table, refused)
63
+ role = connection.quote_table_name name
64
+ quoted = connection.quote_table_name table
65
+ columns = connection.columns(table).map(&:name).grep_v(refused)
66
+ .map { |column| connection.quote_column_name column }
67
+ [ "REVOKE ALL ON #{quoted} FROM #{role}",
68
+ "GRANT SELECT (#{columns.join ', '}) ON #{quoted} TO #{role}", ]
69
+ end
70
+
71
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
72
+ # @param name [String] the role to make, where the database has not got it.
73
+ # @return [Array<String>] the statements that make it and say what it may be.
74
+ def self.made(connection, name)
75
+ role = connection.quote_table_name name
76
+ [ 'DO $$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = ' \
77
+ "#{connection.quote name}) THEN CREATE ROLE #{role} NOLOGIN; END IF; END $$",
78
+ "ALTER ROLE #{role} WITH #{Attributes::SETTABLE}", ]
79
+ end
80
+
29
81
  # Intersected, so a bare db:create with no table yet to revoke on is not a failure.
30
82
  # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
31
83
  # @param role [String] the role to hide this feature's own tables from.
data/lib/omen/inquirer.rb CHANGED
@@ -14,30 +14,30 @@ module Omen
14
14
  UNMADE = 'Could not make %{role}, so every reading will say this app is misconfigured. Ask ' \
15
15
  'for that role, NOLOGIN, granted SELECT on every table but %{tables}.'
16
16
 
17
- # Said per statement, since the grants, the revocations and the functions need nothing from
18
- # one another and one refusal should not discard the rest.
17
+ # Said per group, since the grants, the revocations and the functions need nothing from one
18
+ # another and one refusal should not discard the rest.
19
19
  REFUSED = 'Skipped, refused by the database: %{statement} (%{error})'
20
20
 
21
21
  # Said where a role that already existed is one a reading should not be able to reach through.
22
22
  DANGEROUS = '%{role} holds %{held}. This gem cannot take that away without being a superuser ' \
23
23
  'itself, so ask for it to be taken away.'
24
24
 
25
- # Creates the role and the function, on every database this environment prepares.
25
+ # Creates the role and the functions, and writes every narrowing a host declared, on every
26
+ # database this environment prepares.
26
27
  # @return [void]
27
- def self.grant
28
- environments.each do |environment|
29
- config = ActiveRecord::Base.configurations.configs_for env_name: environment,
30
- name: 'primary'
31
- next unless config
32
- ActiveRecord::Tasks::DatabaseTasks.with_temporary_connection config do |connection|
33
- grant_on connection
28
+ def self.grant = Omen.each_database { |connection| grant_on connection }
29
+
30
+ # Takes every narrowing back off, leaving each table read the way it was read before one.
31
+ # @return [void]
32
+ def self.widen
33
+ Omen.each_database do |connection|
34
+ Omen::Reading.narrowings.each do |narrowing|
35
+ narrowing.widening(connection).each { |group| attempted connection, *group }
36
+ puts "Widened #{connection.current_database} back out of #{narrowing.role}"
34
37
  end
35
38
  end
36
39
  end
37
40
 
38
- # @return [Array<String>] the environments whose databases this run should cover.
39
- def self.environments = Rails.env.development? ? %w[ development test ] : [Rails.env.to_s]
40
-
41
41
  # Warns rather than raises: a managed database never grants CREATEROLE, and a deploy that
42
42
  # cannot make the role must still finish, having said what has to be made by hand.
43
43
  # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
@@ -54,18 +54,34 @@ module Omen
54
54
  held = Attributes.dangerous connection
55
55
  warn DANGEROUS % { role: role, held: held.to_sentence } if held.any?
56
56
  puts "Granted SELECT on #{connection.current_database} to #{role}"
57
+ narrow connection, members
57
58
  end
58
59
 
59
- # One statement at a time, so a database that refuses one still runs the others -- and each
60
- # inside a savepoint of its own, since a refusal inside a transaction refuses everything
61
- # after it too, and this task is as likely to be run from a console as from a deploy.
60
+ # Every audience a host declared, written into the database its readings are answered from.
62
61
  # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
63
- # @param statement [String] one of the statements Omen::Grants builds.
62
+ # @param members [Array<String>] the roles that may enter a narrowed one.
64
63
  # @return [void]
65
- def self.attempted(connection, statement)
66
- connection.transaction(requires_new: true) { connection.execute statement }
64
+ def self.narrow(connection, members)
65
+ Omen::Reading.narrowings.each do |narrowing|
66
+ narrowing.statements(connection, members).each { |group| attempted connection, *group }
67
+ puts "Narrowed #{narrowing.role} to the rows #{narrowing.setting} names"
68
+ end
69
+ end
70
+
71
+ # A group at a time, so a database that refuses one still runs the others -- and each inside
72
+ # a savepoint of its own, since a refusal inside a transaction refuses everything after it
73
+ # too, and this task is as likely to be run from a console as from a deploy. What arrives
74
+ # together is applied together: a table is never left with row level security on and no
75
+ # policy under it.
76
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
77
+ # @param statements [Array<String>] what Omen::Grants or a narrowing built.
78
+ # @return [void]
79
+ def self.attempted(connection, *statements)
80
+ connection.transaction(requires_new: true) do
81
+ statements.each { |statement| connection.execute statement }
82
+ end
67
83
  rescue ActiveRecord::StatementInvalid => error
68
- warn REFUSED % { statement: statement.squish, error: error.message.lines.first.strip }
84
+ warn REFUSED % { statement: statements.first.squish, error: error.message.lines.first.strip }
69
85
  end
70
86
 
71
87
  # Discovered rather than named: SET LOCAL ROLE needs the connecting role to be a member of
@@ -0,0 +1,87 @@
1
+ module Omen
2
+ # What one audience of a reading may read: the role its statement runs as, the rows each
3
+ # table admits it, and the tables it reads whole because nothing in them is anybody's.
4
+ class Narrowing
5
+ # The policy every role holds, so that turning row level security on takes nothing away
6
+ # from whoever could already read -- a role made after this ran included. The narrowed role
7
+ # is held back by the restrictive policy beside it, which is ANDed with this one and
8
+ # applies to nobody else.
9
+ EVERYBODY = 'omen_everybody'
10
+
11
+ # @return [String] the Postgres role a reading of this audience runs as.
12
+ attr_reader :role
13
+
14
+ # @return [Symbol] the column of the reading that says whose rows these are.
15
+ attr_reader :by
16
+
17
+ # @param role [String] the Postgres role a reading of this audience runs as.
18
+ # @param by [Symbol] the column of the reading that says whose rows these are.
19
+ # @param own [Hash] every table it reads rows of, to what makes a row its own. A table is
20
+ # named either way, since a host writing one of these writes symbols or strings by habit.
21
+ # @param whole [Array<String>] the tables it reads whole, nobody's in particular.
22
+ # @param except [Regexp, nil] the columns of those it may not read, a counter over everybody
23
+ # being the case this exists for: it counts every owner rather than this one.
24
+ def initialize(role:, by:, own:, whole: [], except: nil)
25
+ @role = role
26
+ @by = by
27
+ @own = own.transform_keys(&:to_s)
28
+ @whole = whole.map(&:to_s)
29
+ @except = except
30
+ end
31
+
32
+ # @return [String] the setting a policy reads to know whose rows it is looking at.
33
+ def setting = "omen.#{@by}"
34
+
35
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
36
+ # @param members [Array<String>] the roles that may enter this one.
37
+ # @return [Array<Array<String>>] the statements, grouped: what arrives together is applied
38
+ # together, so no table is left with row level security on and no policy under it.
39
+ def statements(connection, members)
40
+ [ Grants.narrowed(connection, @role, members),
41
+ *@whole.map { |table| granted connection, table },
42
+ *@own.map { |table, own| narrowed connection, table, own }, ]
43
+ end
44
+
45
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
46
+ # @return [Array<Array<String>>] the statements that take it back off, table by table.
47
+ def widening(connection)
48
+ @own.keys.map { |table| widened connection, table }
49
+ end
50
+
51
+ private
52
+
53
+ # The permissive policy first and the table turned on last, so a group that stops short
54
+ # leaves a table nobody has been narrowed on rather than one nobody can read.
55
+ def narrowed(connection, table, own)
56
+ quoted = connection.quote_table_name table
57
+ [ *granted(connection, table),
58
+ *policy(connection, quoted, EVERYBODY, 'FOR ALL TO PUBLIC USING (true) WITH CHECK (true)'),
59
+ *policy(connection, quoted, "#{table}_#{@role}",
60
+ "AS RESTRICTIVE FOR SELECT TO #{connection.quote_table_name @role} " \
61
+ "USING (#{format own, owner: owner, setting: setting})"),
62
+ "ALTER TABLE #{quoted} ENABLE ROW LEVEL SECURITY", ]
63
+ end
64
+
65
+ # Dropped first, so that running this again says what it said the first time.
66
+ def policy(connection, quoted, name, rule)
67
+ named = connection.quote_table_name name
68
+ [ "DROP POLICY IF EXISTS #{named} ON #{quoted}",
69
+ "CREATE POLICY #{named} ON #{quoted} #{rule}", ]
70
+ end
71
+
72
+ def widened(connection, table)
73
+ quoted = connection.quote_table_name table
74
+ [ "DROP POLICY IF EXISTS #{connection.quote_table_name "#{table}_#{@role}"} ON #{quoted}",
75
+ "DROP POLICY IF EXISTS #{connection.quote_table_name EVERYBODY} ON #{quoted}",
76
+ "ALTER TABLE #{quoted} DISABLE ROW LEVEL SECURITY", ]
77
+ end
78
+
79
+ # Empty where the reading names nobody, and no row is nobody's, so nobody reads nothing.
80
+ def owner = "NULLIF(current_setting('#{setting}', true), '')::bigint"
81
+
82
+ def granted(connection, table)
83
+ refused = [ Omen::Column::CREDENTIALS, (@except if @whole.include? table) ].compact
84
+ Grants.granted connection, @role, table, Regexp.union(refused)
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,17 @@
1
+ module Omen
2
+ # The names these functions had before 1.0, dropped so a database matches the gem installed in
3
+ # it. A statement stored before the rename names them, and dropping them makes re-running one
4
+ # fail where leaving them would have it quietly answer -- from a function nothing maintains any
5
+ # more. An answer already drawn is unaffected: its rows are stored, not recomputed.
6
+ #
7
+ # Delete this file once no database still has them.
8
+ module Renamed
9
+ # Signatures rather than names, since a function is dropped by the arguments it takes.
10
+ BEFORE = [ 'eastern(timestamp)', 'eastern(timestamptz)',
11
+ 'miles_between(double precision, double precision, double precision, ' \
12
+ 'double precision)', ]
13
+
14
+ # @return [Array<String>] one DROP per function this gem used to create.
15
+ def self.statements = BEFORE.map { |signature| "DROP FUNCTION IF EXISTS #{signature}" }
16
+ end
17
+ end
@@ -0,0 +1,57 @@
1
+ module Omen
2
+ # The zone the company works in, and the two questions a reading asks about it: what a stored
3
+ # timestamp says here, and what day it is here. Created by the rake task rather than by a
4
+ # migration, because Rails' :ruby schema format dumps no functions, so db:schema:load would
5
+ # drop one a migration had made -- and the format cannot become :sql, since db/schema.rb is
6
+ # what Claude is shown.
7
+ module TimeZone
8
+ # The zone as Postgres names one: Time.zone.name is not a name it takes.
9
+ ZONE = 'America/New_York'
10
+
11
+ # DDL, which Active Record has no expression for, and not a query.
12
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
13
+ # @return [Array<String>] the statements to run, in order.
14
+ def self.statements(connection) = [ stored(connection), instant(connection), today(connection) ]
15
+
16
+ # A stored timestamp says nothing about its own zone, so it is named UTC and then rendered.
17
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
18
+ # @return [String] the statement declaring the function over a naked timestamp.
19
+ def self.stored(connection)
20
+ "CREATE OR REPLACE FUNCTION #{name connection}(ts timestamp) RETURNS timestamp AS " \
21
+ "$$ SELECT ts AT TIME ZONE 'UTC' AT TIME ZONE #{connection.quote ZONE} $$ " \
22
+ 'LANGUAGE sql IMMUTABLE'
23
+ end
24
+
25
+ # One conversion and not two: an instant already knows which moment it is, so naming it UTC
26
+ # first would convert it twice and answer hours out. Overloaded because Postgres will not
27
+ # cast an instant to a timestamp to resolve a call, so now() reaches neither without it.
28
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
29
+ # @return [String] the statement declaring the function over an instant.
30
+ def self.instant(connection)
31
+ "CREATE OR REPLACE FUNCTION #{name connection}(ts timestamptz) RETURNS timestamp AS " \
32
+ "$$ SELECT ts AT TIME ZONE #{connection.quote ZONE} $$ LANGUAGE sql IMMUTABLE"
33
+ end
34
+
35
+ # What makes a window slide: a statement saying `date_trunc('month', omen_today())` answers
36
+ # for whichever month it is run in, where a literal answers for the month it was written in.
37
+ #
38
+ # STABLE and not IMMUTABLE, unlike its neighbours, which are pure functions of what they are
39
+ # given. This one reads the clock: marked immutable, Postgres is entitled to fold it to a
40
+ # constant, and a window would stop moving in the way this function exists to prevent. Stable
41
+ # is also what has it evaluated once per statement, so a query naming it five times cannot
42
+ # straddle midnight.
43
+ #
44
+ # A date and not a timestamp, so a window cannot silently mean "up to the current hour".
45
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
46
+ # @return [String] the statement declaring the function.
47
+ def self.today(connection)
48
+ "CREATE OR REPLACE FUNCTION #{connection.quote_table_name Omen::Instructions::TODAY}() " \
49
+ "RETURNS date AS $$ SELECT (now() AT TIME ZONE #{connection.quote ZONE})::date $$ " \
50
+ 'LANGUAGE sql STABLE'
51
+ end
52
+
53
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
54
+ # @return [String] the function's name, quoted.
55
+ def self.name(connection) = connection.quote_table_name Omen::Instructions::TIME_ZONE
56
+ end
57
+ end
data/lib/omen/version.rb CHANGED
@@ -1,4 +1,4 @@
1
1
  module Omen
2
2
  # The version of this gem, as RubyGems knows it.
3
- VERSION = '0.3.1'
3
+ VERSION = '0.5.0'
4
4
  end
data/lib/omen.rb CHANGED
@@ -4,8 +4,11 @@ require 'anthropic'
4
4
  require 'omen/attributes'
5
5
  require 'omen/config'
6
6
  require 'omen/distance'
7
- require 'omen/eastern'
8
7
  require 'omen/grants'
8
+ require 'omen/renamed'
9
+ require 'omen/time_zone'
10
+ require 'omen/bound'
11
+ require 'omen/narrowing'
9
12
  require 'omen/inquirer'
10
13
  require 'omen/requirements'
11
14
  require 'omen/version'
@@ -23,4 +26,19 @@ module Omen
23
26
  # Yields the configuration, so a host states its own facts in one initializer.
24
27
  # @return [void]
25
28
  def self.configure = yield config
29
+
30
+ # @return [Array<String>] the environments whose databases a grant should cover.
31
+ def self.environments = Rails.env.development? ? %w[ development test ] : [Rails.env.to_s]
32
+
33
+ # @yield [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing connection to each
34
+ # database this environment prepares.
35
+ # @return [void]
36
+ def self.each_database
37
+ environments.each do |environment|
38
+ config = ActiveRecord::Base.configurations.configs_for env_name: environment,
39
+ name: 'primary'
40
+ next unless config
41
+ ActiveRecord::Tasks::DatabaseTasks.with_temporary_connection(config) { |it| yield it }
42
+ end
43
+ end
26
44
  end
data/lib/tasks/omen.rake CHANGED
@@ -1,9 +1,23 @@
1
1
  namespace :db do
2
2
  namespace :omen do
3
- desc 'Create the role a reading runs its statement as, and the function it reads a ' \
4
- 'timestamp through'
3
+ desc 'Create the role a reading runs its statement as, the function it reads a ' \
4
+ 'timestamp through, and the policies every narrowing a host declared asks for'
5
5
  task grant: :environment do
6
6
  Omen::Inquirer.grant
7
7
  end
8
+
9
+ desc 'Take every narrowing back off, leaving each table read the way it was read before'
10
+ task widen: :environment do
11
+ Omen::Inquirer.widen
12
+ end
13
+
14
+ desc 'Say which roles the policies of a narrowing hold back, membership included'
15
+ task narrowed: :environment do
16
+ Omen.config.record.with_connection do |connection|
17
+ Omen::Bound.roles(connection).each { |row| puts row.join ' ' }
18
+ surprises = Omen::Bound.surprises connection
19
+ abort "Narrowed without being asked for: #{surprises.inspect}" if surprises.any?
20
+ end
21
+ end
8
22
  end
9
23
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: omen
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Claudio Baccigalupo
@@ -73,6 +73,7 @@ extensions: []
73
73
  extra_rdoc_files: []
74
74
  files:
75
75
  - CHANGELOG.md
76
+ - INSTRUCTIONS.md
76
77
  - LICENSE.txt
77
78
  - README.md
78
79
  - app/models/omen/answer.rb
@@ -83,9 +84,12 @@ files:
83
84
  - app/models/omen/executed.rb
84
85
  - app/models/omen/instructions.md
85
86
  - app/models/omen/instructions.rb
87
+ - app/models/omen/narrowed.rb
86
88
  - app/models/omen/query.rb
87
89
  - app/models/omen/question.rb
88
90
  - app/models/omen/reading.rb
91
+ - app/models/omen/refusal.rb
92
+ - app/models/omen/reply.rb
89
93
  - app/models/omen/revealed.rb
90
94
  - app/models/omen/role.rb
91
95
  - app/models/omen/schema.rb
@@ -94,18 +98,29 @@ files:
94
98
  - db/migrate/20260821120000_create_omen_readings.rb
95
99
  - db/migrate/20260821120001_create_omen_questions.rb
96
100
  - db/migrate/20260821120002_create_omen_answers.rb
101
+ - db/migrate/20260912000000_add_type_to_omen_readings.rb
97
102
  - lib/generators/omen/install/install_generator.rb
98
103
  - lib/generators/omen/install/templates/omen.rb
104
+ - lib/generators/omen/pages/USAGE
105
+ - lib/generators/omen/pages/pages_generator.rb
106
+ - lib/generators/omen/pages/templates/_form.html.erb.tt
107
+ - lib/generators/omen/pages/templates/controller.rb.tt
108
+ - lib/generators/omen/pages/templates/index.html.erb.tt
109
+ - lib/generators/omen/pages/templates/model.rb.tt
110
+ - lib/generators/omen/pages/templates/show.html.erb.tt
99
111
  - lib/omen.rb
100
112
  - lib/omen/attributes.rb
113
+ - lib/omen/bound.rb
101
114
  - lib/omen/config.rb
102
115
  - lib/omen/distance.rb
103
- - lib/omen/eastern.rb
104
116
  - lib/omen/engine.rb
105
117
  - lib/omen/grants.rb
106
118
  - lib/omen/inquirer.rb
119
+ - lib/omen/narrowing.rb
120
+ - lib/omen/renamed.rb
107
121
  - lib/omen/requirements.rb
108
122
  - lib/omen/stubs.rb
123
+ - lib/omen/time_zone.rb
109
124
  - lib/omen/version.rb
110
125
  - lib/tasks/omen.rake
111
126
  homepage: https://github.com/claudiob/omen
data/lib/omen/eastern.rb DELETED
@@ -1,40 +0,0 @@
1
- module Omen
2
- # The database function a reading's SQL reads every timestamp through. Created by the rake
3
- # task rather than by a migration, because Rails' :ruby schema format dumps no functions, so
4
- # db:schema:load would drop one a migration had made -- and the format cannot become :sql,
5
- # since db/schema.rb is what Claude is shown.
6
- module Eastern
7
- # The zone the company works in, as Postgres names one: Time.zone.name is not one it takes.
8
- ZONE = 'America/New_York'
9
-
10
- # DDL, which Active Record has no expression for, and not a query.
11
- # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
12
- # @return [Array<String>] the statements to run, in order.
13
- def self.statements(connection)
14
- [ stored(connection), instant(connection) ]
15
- end
16
-
17
- # A stored timestamp says nothing about its own zone, so it is named UTC and then rendered.
18
- # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
19
- # @return [String] the statement declaring the function over a naked timestamp.
20
- def self.stored(connection)
21
- "CREATE OR REPLACE FUNCTION #{name connection}(ts timestamp) RETURNS timestamp AS " \
22
- "$$ SELECT ts AT TIME ZONE 'UTC' AT TIME ZONE #{connection.quote ZONE} $$ " \
23
- 'LANGUAGE sql IMMUTABLE'
24
- end
25
-
26
- # One conversion and not two: an instant already knows which moment it is, so naming it UTC
27
- # first would convert it twice and answer hours out. Overloaded because Postgres will not
28
- # cast an instant to a timestamp to resolve a call, so now() reaches neither without it.
29
- # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
30
- # @return [String] the statement declaring the function over an instant.
31
- def self.instant(connection)
32
- "CREATE OR REPLACE FUNCTION #{name connection}(ts timestamptz) RETURNS timestamp AS " \
33
- "$$ SELECT ts AT TIME ZONE #{connection.quote ZONE} $$ LANGUAGE sql IMMUTABLE"
34
- end
35
-
36
- # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] a writing one.
37
- # @return [String] the function's name, quoted.
38
- def self.name(connection) = connection.quote_table_name Omen::Instructions::EASTERN
39
- end
40
- end