logica_compiler 0.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.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +138 -0
  4. data/Rakefile +76 -0
  5. data/exe/logica_compiler +7 -0
  6. data/lib/logica_compiler/active_record/runner.rb +89 -0
  7. data/lib/logica_compiler/cli.rb +76 -0
  8. data/lib/logica_compiler/commands/base.rb +45 -0
  9. data/lib/logica_compiler/commands/clean.rb +25 -0
  10. data/lib/logica_compiler/commands/compile.rb +57 -0
  11. data/lib/logica_compiler/commands/install.rb +21 -0
  12. data/lib/logica_compiler/commands/logica.rb +61 -0
  13. data/lib/logica_compiler/commands/version.rb +14 -0
  14. data/lib/logica_compiler/commands/watch.rb +23 -0
  15. data/lib/logica_compiler/compiler.rb +304 -0
  16. data/lib/logica_compiler/config.rb +99 -0
  17. data/lib/logica_compiler/deps/clock.rb +10 -0
  18. data/lib/logica_compiler/deps/env.rb +35 -0
  19. data/lib/logica_compiler/deps/shell.rb +31 -0
  20. data/lib/logica_compiler/deps/sleeper.rb +11 -0
  21. data/lib/logica_compiler/errors.rb +22 -0
  22. data/lib/logica_compiler/installer.rb +204 -0
  23. data/lib/logica_compiler/manifest.rb +31 -0
  24. data/lib/logica_compiler/railtie.rb +11 -0
  25. data/lib/logica_compiler/registry.rb +97 -0
  26. data/lib/logica_compiler/sql_dialect/postgres.rb +13 -0
  27. data/lib/logica_compiler/sql_dialect/sqlite.rb +13 -0
  28. data/lib/logica_compiler/sql_safety.rb +176 -0
  29. data/lib/logica_compiler/tasks/logica_compiler.rake +20 -0
  30. data/lib/logica_compiler/templates/bin_logica.erb +6 -0
  31. data/lib/logica_compiler/templates/config.yml.erb +7 -0
  32. data/lib/logica_compiler/templates/gitignore_block.erb +3 -0
  33. data/lib/logica_compiler/templates/hello_world.l.erb +9 -0
  34. data/lib/logica_compiler/templates/initializer.rb.erb +28 -0
  35. data/lib/logica_compiler/templates/requirements.txt.erb +1 -0
  36. data/lib/logica_compiler/util.rb +51 -0
  37. data/lib/logica_compiler/version.rb +5 -0
  38. data/lib/logica_compiler/watcher.rb +83 -0
  39. data/lib/logica_compiler.rb +13 -0
  40. data/sig/logica_compiler.rbs +4 -0
  41. metadata +177 -0
@@ -0,0 +1,304 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+ require "open3"
7
+ require "time"
8
+ require "timeout"
9
+
10
+ require_relative "sql_safety"
11
+ require_relative "util"
12
+
13
+ module LogicaCompiler
14
+ class Compiler
15
+ DEFAULT_TIMEOUT = 30
16
+
17
+ # User-friendly aliases → Logica CLI engine names.
18
+ # Python Logica uses "psql" as the PostgreSQL engine name (not "postgres").
19
+ ENGINE_ALIASES = {
20
+ "postgres" => "psql",
21
+ "postgresql" => "psql",
22
+ "pg" => "psql",
23
+ }.freeze
24
+
25
+ def initialize(config:)
26
+ @config = config
27
+ @logica_version = nil
28
+ end
29
+
30
+ def compile_all!(force: false, prune: true)
31
+ version = logica_version
32
+ out_dir = @config.output_dir_path
33
+ FileUtils.mkdir_p(out_dir)
34
+
35
+ entries = {}
36
+ @config.queries.each do |name, query|
37
+ entries[name.to_s] = compile_one!(name:, query:, force:, prune:, logica_version: version)
38
+ end
39
+
40
+ manifest = Manifest.build(
41
+ engine: @config.engine,
42
+ logica_version: version,
43
+ queries: entries
44
+ )
45
+ Manifest.write!(@config.manifest_path, manifest)
46
+ manifest
47
+ end
48
+
49
+ def compile_one!(name:, query:, force: false, prune: true, logica_version: self.logica_version)
50
+ out_dir = @config.output_dir_path
51
+ FileUtils.mkdir_p(out_dir)
52
+
53
+ program_path = @config.absolute_path(query.program)
54
+ program_source = File.read(program_path)
55
+ validate_program_engine!(program_source)
56
+
57
+ digest_hex = compute_digest(program_source:, predicate: query.predicate, engine: @config.engine, logica_version:)
58
+ digest = "sha256:#{digest_hex}"
59
+
60
+ sql_filename = "#{name}-#{digest_hex}.sql"
61
+ meta_filename = "#{name}-#{digest_hex}.meta.json"
62
+ sql_path = out_dir.join(sql_filename)
63
+ meta_path = out_dir.join(meta_filename)
64
+
65
+ return existing_entry_for(name:) if !force && entry_exists?(name:, digest_hex:)
66
+
67
+ prune_old_artifacts!(name:, keep_digest_hex: digest_hex) if prune
68
+
69
+ compiled_sql = compile_via_cli!(program_source:, predicate: query.predicate, timeout: compile_timeout)
70
+ compiled_sql = normalize_sql(compiled_sql)
71
+
72
+ sql_with_header = add_sql_header(
73
+ sql: compiled_sql,
74
+ name: name.to_s,
75
+ program: query.program,
76
+ predicate: query.predicate,
77
+ engine: @config.engine,
78
+ digest:
79
+ )
80
+ SqlSafety.validate!(sql_with_header)
81
+
82
+ File.write(sql_path, sql_with_header)
83
+
84
+ meta = {
85
+ name: name.to_s,
86
+ program: query.program,
87
+ predicate: query.predicate,
88
+ engine: @config.engine,
89
+ compiled_at: Time.now.utc.iso8601,
90
+ compiler: { bin: @config.logica_bin, version: logica_version },
91
+ digest: digest,
92
+ }
93
+ File.write(meta_path, "#{JSON.pretty_generate(meta)}\n")
94
+
95
+ { "digest" => digest, "sql" => sql_filename, "meta" => meta_filename }
96
+ end
97
+
98
+ def logica_version
99
+ return @logica_version if @logica_version
100
+
101
+ pinned = @config.pinned_logica_version
102
+ return (@logica_version = pinned) if pinned && !pinned.to_s.strip.empty?
103
+
104
+ # If LOGICA_BIN points to a venv-installed executable, infer the venv python and ask pip metadata.
105
+ bin = @config.logica_bin.to_s
106
+ if Util.path_like?(bin)
107
+ venv_dir = File.expand_path("..", File.dirname(bin))
108
+ python = File.join(venv_dir, Util.venv_bin_dir, "python")
109
+ if File.executable?(python)
110
+ stdout, _stderr, status =
111
+ run_cmd_with_timeout!(
112
+ [python, "-c", "import importlib.metadata as m; print(m.version('logica'))"],
113
+ stdin_data: nil,
114
+ timeout: 5
115
+ )
116
+ version = stdout.to_s.strip
117
+ return (@logica_version = version) if status.success? && !version.empty?
118
+ end
119
+ end
120
+
121
+ @logica_version = "unknown"
122
+ rescue StandardError
123
+ @logica_version = @config.pinned_logica_version || "unknown"
124
+ end
125
+
126
+ private
127
+
128
+ def compile_timeout
129
+ Integer(ENV.fetch("LOGICA_COMPILE_TIMEOUT", DEFAULT_TIMEOUT))
130
+ rescue ArgumentError
131
+ DEFAULT_TIMEOUT
132
+ end
133
+
134
+ def entry_exists?(name:, digest_hex:)
135
+ entry = existing_entry_for(name:)
136
+ return false unless entry
137
+ return false unless entry["digest"].to_s == "sha256:#{digest_hex}"
138
+
139
+ out_dir = @config.output_dir_path
140
+ sql_file = entry["sql"].to_s
141
+ meta_file = entry["meta"].to_s
142
+ return false if sql_file.empty? || meta_file.empty?
143
+
144
+ out_dir.join(sql_file).exist? && out_dir.join(meta_file).exist?
145
+ end
146
+
147
+ def existing_entry_for(name:)
148
+ manifest = Manifest.load(@config.manifest_path)
149
+ manifest.fetch("queries", {}).fetch(name.to_s)
150
+ rescue Errno::ENOENT, KeyError, JSON::ParserError
151
+ nil
152
+ end
153
+
154
+ def compute_digest(program_source:, predicate:, engine:, logica_version:)
155
+ Digest::SHA256.hexdigest([program_source, predicate, engine, logica_version].join("\n"))
156
+ end
157
+
158
+ def normalize_sql(sql)
159
+ sql = sql.to_s
160
+ sql = extract_main_query_from_psql_script(sql) if logica_engine == "psql"
161
+ sql.strip.sub(/;\s*\z/, "")
162
+ end
163
+
164
+ def validate_program_engine!(program_source)
165
+ declared = program_source.match(/@Engine\(\s*["']([^"']+)["']\s*\)\s*;/i)&.captures&.first
166
+ return if declared.nil?
167
+
168
+ return if canonical_engine(declared) == logica_engine
169
+
170
+ raise ArgumentError, "Program @Engine(#{declared.inspect}) does not match config engine #{@config.engine.inspect}"
171
+ end
172
+
173
+ def compile_via_cli!(program_source:, predicate:, timeout:)
174
+ effective_source = ensure_engine_directive(program_source)
175
+ cmd = [@config.logica_bin, "-", "print", predicate.to_s]
176
+ stdout, stderr, status = run_cmd_with_timeout!(cmd, stdin_data: effective_source, timeout:)
177
+ raise CompileError, "Logica compile failed: #{stderr}" unless status.success?
178
+
179
+ stdout
180
+ end
181
+
182
+ def ensure_engine_directive(program_source)
183
+ engine = logica_engine
184
+
185
+ # If present, normalize to the canonical Logica engine name (e.g., postgres -> psql).
186
+ if program_source.match?(/@Engine\(\s*["'][^"']+["']\s*\)\s*;/i)
187
+ return program_source.sub(/@Engine\(\s*(["'])([^"']+)\1\s*\)\s*;/i) do
188
+ quote = Regexp.last_match(1)
189
+ "@Engine(#{quote}#{engine}#{quote});"
190
+ end
191
+ end
192
+
193
+ "@Engine(\"#{engine}\");\n#{program_source}"
194
+ end
195
+
196
+ def canonical_engine(engine)
197
+ value = engine.to_s.strip
198
+ return "" if value.empty?
199
+
200
+ down = value.downcase
201
+ ENGINE_ALIASES.fetch(down, down)
202
+ end
203
+
204
+ def logica_engine
205
+ canonical_engine(@config.engine)
206
+ end
207
+
208
+ def extract_main_query_from_psql_script(sql)
209
+ # Logica's psql engine prints a multi-statement script (preamble + final query).
210
+ # Our runner expects a single SELECT/WITH statement, so keep only the last statement
211
+ # that starts with SELECT/WITH, ignoring semicolons inside strings/comments/dollar-quotes.
212
+ sanitized = SqlSafety.strip_strings_and_comments(sql)
213
+
214
+ statement_starts = [0]
215
+ sanitized.to_enum(:scan, /;/).each do
216
+ statement_starts << Regexp.last_match.end(0)
217
+ end
218
+
219
+ last = nil
220
+ statement_starts.each do |start|
221
+ i = start
222
+ i += 1 while i < sanitized.length && sanitized[i].match?(/\s/)
223
+ next if i >= sanitized.length
224
+
225
+ last = i if sanitized[i..].match?(/\A(?:WITH|SELECT)\b/i)
226
+ end
227
+
228
+ last ? sql[last..] : sql
229
+ end
230
+
231
+ def add_sql_header(sql:, name:, program:, predicate:, engine:, digest:)
232
+ header = <<~SQL
233
+ -- LogicaCompiler
234
+ -- name: #{name}
235
+ -- program: #{program}
236
+ -- predicate: #{predicate}
237
+ -- engine: #{engine}
238
+ -- digest: #{digest}
239
+ -- compiled_at: #{Time.now.utc.iso8601}
240
+ SQL
241
+
242
+ "#{header}\n#{sql.strip}\n"
243
+ end
244
+
245
+ def prune_old_artifacts!(name:, keep_digest_hex:)
246
+ out_dir = @config.output_dir_path
247
+
248
+ Dir.glob(out_dir.join("#{name}-*.sql").to_s).each do |path|
249
+ next if path.end_with?("-#{keep_digest_hex}.sql")
250
+
251
+ FileUtils.rm_f(path)
252
+ end
253
+
254
+ Dir.glob(out_dir.join("#{name}-*.meta.json").to_s).each do |path|
255
+ next if path.end_with?("-#{keep_digest_hex}.meta.json")
256
+
257
+ FileUtils.rm_f(path)
258
+ end
259
+ end
260
+
261
+ def run_cmd_with_timeout!(cmd, stdin_data:, timeout:)
262
+ stdout_str = +""
263
+ stderr_str = +""
264
+
265
+ Open3.popen3(*cmd) do |stdin, stdout, stderr, wait_thr|
266
+ stdin.binmode
267
+ stdout.binmode
268
+ stderr.binmode
269
+
270
+ stdin.write(stdin_data) if stdin_data
271
+ stdin.close
272
+
273
+ out_reader = Thread.new { stdout_str << stdout.read.to_s }
274
+ err_reader = Thread.new { stderr_str << stderr.read.to_s }
275
+
276
+ status = nil
277
+ begin
278
+ status = Timeout.timeout(timeout) { wait_thr.value }
279
+ rescue Timeout::Error
280
+ terminate_process(wait_thr.pid)
281
+ raise
282
+ ensure
283
+ out_reader.join
284
+ err_reader.join
285
+ end
286
+
287
+ [stdout_str, stderr_str, status]
288
+ end
289
+ rescue Timeout::Error
290
+ raise Timeout::Error, "Command timed out after #{timeout}s: #{cmd.join(" ")}"
291
+ end
292
+
293
+ def terminate_process(pid)
294
+ Process.kill("TERM", pid)
295
+ Timeout.timeout(2) { Process.wait(pid) }
296
+ rescue Errno::ESRCH, Errno::ECHILD, Timeout::Error
297
+ begin
298
+ Process.kill("KILL", pid)
299
+ rescue Errno::ESRCH
300
+ nil
301
+ end
302
+ end
303
+ end
304
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "yaml"
5
+
6
+ require_relative "util"
7
+
8
+ module LogicaCompiler
9
+ class Config
10
+ Query = Struct.new(:program, :predicate, keyword_init: true) do
11
+ def initialize(program:, predicate:)
12
+ super(program: program.to_s, predicate: predicate.to_s)
13
+ end
14
+ end
15
+
16
+ DEFAULT_ENGINE = "postgres"
17
+ DEFAULT_OUTPUT_DIR = "logica/compiled"
18
+
19
+ attr_reader :config_path, :root, :engine, :output_dir, :queries, :logica_bin
20
+
21
+ def self.load!(config_path = "logica/config.yml", root: default_root)
22
+ new(config_path:, root:).tap(&:load!)
23
+ end
24
+
25
+ def self.default_root
26
+ if defined?(Rails) && Rails.respond_to?(:root)
27
+ Rails.root
28
+ else
29
+ Pathname.new(Dir.pwd)
30
+ end
31
+ end
32
+
33
+ def initialize(config_path:, root:)
34
+ @config_path = config_path.to_s
35
+ @root = Pathname(root)
36
+ @engine = DEFAULT_ENGINE
37
+ @output_dir = DEFAULT_OUTPUT_DIR
38
+ @queries = {}
39
+ @logica_bin = env_logica_bin
40
+ end
41
+
42
+ def load!
43
+ data = YAML.safe_load(File.read(absolute_path(config_path)), permitted_classes: [], aliases: false) || {}
44
+
45
+ @engine = (data["engine"] || DEFAULT_ENGINE).to_s
46
+ @output_dir = (data["output_dir"] || DEFAULT_OUTPUT_DIR).to_s
47
+ @logica_bin ||= (data["logica_bin"] || default_logica_bin).to_s
48
+
49
+ queries = (data["queries"] || {}).to_h
50
+ @queries = queries.each_with_object({}) do |(name, attrs), h|
51
+ attrs = attrs.to_h
52
+ h[name.to_sym] = Query.new(
53
+ program: attrs.fetch("program"),
54
+ predicate: attrs.fetch("predicate")
55
+ )
56
+ end
57
+
58
+ self
59
+ end
60
+
61
+ def output_dir_path = root.join(output_dir)
62
+ def manifest_path = output_dir_path.join("manifest.json")
63
+ def requirements_path = root.join("logica/requirements.txt")
64
+ def absolute_path(rel_path) = root.join(rel_path).to_s
65
+
66
+ def pinned_logica_version
67
+ req = requirements_path
68
+ return nil unless req.exist?
69
+
70
+ line = req.read.lines.map(&:strip).find { _1.start_with?("logica==") }
71
+ return nil unless line
72
+
73
+ line.split("==", 2).last&.strip
74
+ end
75
+
76
+ private
77
+
78
+ def env_logica_bin
79
+ value = ENV["LOGICA_BIN"]
80
+ value = nil if value.to_s.strip.empty?
81
+ value
82
+ end
83
+
84
+ def default_logica_bin
85
+ # Default lookup order (when LOGICA_BIN / config.yml logica_bin is not set):
86
+ # 1) Project python venv: .venv/bin/logica (or .venv/Scripts/logica.exe on Windows)
87
+ # 2) System-installed logica on PATH
88
+ # 3) Gem-managed venv: tmp/logica_venv/.../logica (installed via `bin/logica install`)
89
+ project_venv_dir = root.join(".venv", Util.venv_bin_dir)
90
+ if (path = Util.find_executable_in_dir(project_venv_dir, "logica"))
91
+ return path
92
+ end
93
+
94
+ return "logica" if Util.which("logica")
95
+
96
+ root.join("tmp/logica_venv", Util.venv_bin_dir, "logica")
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogicaCompiler
4
+ module Deps
5
+ class Clock
6
+ def now = Time.now
7
+ def now_utc = Time.now.utc
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogicaCompiler
4
+ module Deps
5
+ class Env
6
+ MISSING = :__missing__
7
+
8
+ def [](key) = ENV[key]
9
+ def fetch(key, *args, &block) = ENV.fetch(key, *args, &block)
10
+ def key?(key) = ENV.key?(key)
11
+
12
+ def set(key, value)
13
+ ENV[key] = value
14
+ end
15
+
16
+ def delete(key)
17
+ ENV.delete(key)
18
+ end
19
+
20
+ def with_temp(updates)
21
+ previous = {}
22
+ updates.each do |k, v|
23
+ previous[k] = key?(k) ? ENV[k] : MISSING
24
+ v.nil? ? delete(k) : set(k, v)
25
+ end
26
+
27
+ yield
28
+ ensure
29
+ previous.each do |k, v|
30
+ v == MISSING ? delete(k) : set(k, v)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module LogicaCompiler
6
+ module Deps
7
+ class Shell
8
+ def system(*args)
9
+ Kernel.system(*args)
10
+ end
11
+
12
+ def system!(*args)
13
+ ok = system(*args)
14
+ raise CommandFailedError.new("Command failed: #{args.join(" ")}", command: args.join(" ")) unless ok
15
+
16
+ true
17
+ end
18
+
19
+ def capture3(*args)
20
+ Open3.capture3(*args)
21
+ end
22
+
23
+ def capture!(*args)
24
+ out, err, status = capture3(*args)
25
+ raise CommandFailedError.new(err.to_s, command: args.join(" ")) unless status.success?
26
+
27
+ out
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogicaCompiler
4
+ module Deps
5
+ class Sleeper
6
+ def sleep(duration = nil)
7
+ duration.nil? ? Kernel.sleep : Kernel.sleep(duration)
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LogicaCompiler
4
+ class Error < StandardError; end
5
+
6
+ class CommandFailedError < Error
7
+ attr_reader :command
8
+
9
+ def initialize(message = nil, command: nil)
10
+ @command = command
11
+ super(message)
12
+ end
13
+ end
14
+
15
+ class ConfigurationError < Error; end
16
+ class UnknownQueryError < ConfigurationError; end
17
+
18
+ class InstallError < Error; end
19
+ class CompileError < Error; end
20
+ class LogicaError < Error; end
21
+ class WatcherError < Error; end
22
+ end