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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +138 -0
- data/Rakefile +76 -0
- data/exe/logica_compiler +7 -0
- data/lib/logica_compiler/active_record/runner.rb +89 -0
- data/lib/logica_compiler/cli.rb +76 -0
- data/lib/logica_compiler/commands/base.rb +45 -0
- data/lib/logica_compiler/commands/clean.rb +25 -0
- data/lib/logica_compiler/commands/compile.rb +57 -0
- data/lib/logica_compiler/commands/install.rb +21 -0
- data/lib/logica_compiler/commands/logica.rb +61 -0
- data/lib/logica_compiler/commands/version.rb +14 -0
- data/lib/logica_compiler/commands/watch.rb +23 -0
- data/lib/logica_compiler/compiler.rb +304 -0
- data/lib/logica_compiler/config.rb +99 -0
- data/lib/logica_compiler/deps/clock.rb +10 -0
- data/lib/logica_compiler/deps/env.rb +35 -0
- data/lib/logica_compiler/deps/shell.rb +31 -0
- data/lib/logica_compiler/deps/sleeper.rb +11 -0
- data/lib/logica_compiler/errors.rb +22 -0
- data/lib/logica_compiler/installer.rb +204 -0
- data/lib/logica_compiler/manifest.rb +31 -0
- data/lib/logica_compiler/railtie.rb +11 -0
- data/lib/logica_compiler/registry.rb +97 -0
- data/lib/logica_compiler/sql_dialect/postgres.rb +13 -0
- data/lib/logica_compiler/sql_dialect/sqlite.rb +13 -0
- data/lib/logica_compiler/sql_safety.rb +176 -0
- data/lib/logica_compiler/tasks/logica_compiler.rake +20 -0
- data/lib/logica_compiler/templates/bin_logica.erb +6 -0
- data/lib/logica_compiler/templates/config.yml.erb +7 -0
- data/lib/logica_compiler/templates/gitignore_block.erb +3 -0
- data/lib/logica_compiler/templates/hello_world.l.erb +9 -0
- data/lib/logica_compiler/templates/initializer.rb.erb +28 -0
- data/lib/logica_compiler/templates/requirements.txt.erb +1 -0
- data/lib/logica_compiler/util.rb +51 -0
- data/lib/logica_compiler/version.rb +5 -0
- data/lib/logica_compiler/watcher.rb +83 -0
- data/lib/logica_compiler.rb +13 -0
- data/sig/logica_compiler.rbs +4 -0
- metadata +177 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "pathname"
|
|
6
|
+
require "yaml"
|
|
7
|
+
|
|
8
|
+
module LogicaCompiler
|
|
9
|
+
class Installer
|
|
10
|
+
PINNED_LOGICA_VERSION = "1.3.1415926535897"
|
|
11
|
+
|
|
12
|
+
DEFAULT_ENGINE = "postgres"
|
|
13
|
+
DEFAULT_OUTPUT_DIR = "logica/compiled"
|
|
14
|
+
|
|
15
|
+
SAMPLE_PROGRAM_REL = "logica/programs/hello_world.l"
|
|
16
|
+
SAMPLE_QUERY_NAME = "hello_world"
|
|
17
|
+
SAMPLE_PREDICATE = "Greet"
|
|
18
|
+
|
|
19
|
+
def initialize(root:, force: false, stdout: $stdout, stderr: $stderr)
|
|
20
|
+
@root = Pathname(root)
|
|
21
|
+
@force = !!force
|
|
22
|
+
@stdout = stdout
|
|
23
|
+
@stderr = stderr
|
|
24
|
+
@conflicts = []
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def run!
|
|
28
|
+
ensure_dir(@root.join("logica/programs"))
|
|
29
|
+
ensure_dir(@root.join("logica/compiled"))
|
|
30
|
+
|
|
31
|
+
ensure_file(@root.join("logica/programs/.keep"), "", mode: nil)
|
|
32
|
+
ensure_file(@root.join("logica/compiled/.keep"), "", mode: nil)
|
|
33
|
+
|
|
34
|
+
ensure_template(@root.join(SAMPLE_PROGRAM_REL), "hello_world.l", mode: nil)
|
|
35
|
+
ensure_config_yml
|
|
36
|
+
ensure_requirements
|
|
37
|
+
ensure_gitignore_block
|
|
38
|
+
ensure_template(@root.join("bin/logica"), "bin_logica", mode: 0o755)
|
|
39
|
+
ensure_template(@root.join("config/initializers/logica_compiler.rb"), "initializer.rb", mode: nil)
|
|
40
|
+
|
|
41
|
+
if @conflicts.any?
|
|
42
|
+
@stderr.puts "\nConflicts detected. No files were overwritten."
|
|
43
|
+
@stderr.puts @conflicts.map { |p| " - #{p}" }.join("\n")
|
|
44
|
+
@stderr.puts "\nHow to resolve:"
|
|
45
|
+
@stderr.puts " - Re-run with FORCE=1 to overwrite conflicting files"
|
|
46
|
+
@stderr.puts " - Or manually merge the templates and re-run"
|
|
47
|
+
@stderr.puts "Template source: #{template_root}"
|
|
48
|
+
raise InstallError, "LogicaCompiler install aborted due to conflicts (FORCE=1 to overwrite)."
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
say("done", "Installed LogicaCompiler integration into #{@root}")
|
|
52
|
+
say("next", "bin/logica install")
|
|
53
|
+
say("next", "bin/logica compile")
|
|
54
|
+
true
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def template_root
|
|
60
|
+
Pathname(__dir__).join("templates")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def template_vars
|
|
64
|
+
{
|
|
65
|
+
engine: DEFAULT_ENGINE,
|
|
66
|
+
output_dir: DEFAULT_OUTPUT_DIR,
|
|
67
|
+
sample_program_rel: SAMPLE_PROGRAM_REL,
|
|
68
|
+
sample_query_name: SAMPLE_QUERY_NAME,
|
|
69
|
+
sample_predicate: SAMPLE_PREDICATE,
|
|
70
|
+
pinned_logica_version: PINNED_LOGICA_VERSION,
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def render_template(name)
|
|
75
|
+
path = template_root.join("#{name}.erb")
|
|
76
|
+
ERB.new(path.read, trim_mode: "-").result_with_hash(template_vars)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def ensure_dir(path)
|
|
80
|
+
path = Pathname(path)
|
|
81
|
+
return say("exist", path.to_s) if path.exist?
|
|
82
|
+
|
|
83
|
+
FileUtils.mkdir_p(path)
|
|
84
|
+
say("create", path.to_s)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def ensure_template(dest, template_name, mode:)
|
|
88
|
+
content = render_template(template_name)
|
|
89
|
+
ensure_file(dest, content, mode:)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def ensure_file(dest, content, mode:)
|
|
93
|
+
dest = Pathname(dest)
|
|
94
|
+
|
|
95
|
+
if dest.exist?
|
|
96
|
+
existing = dest.read
|
|
97
|
+
return say("identical", dest.to_s) if existing == content
|
|
98
|
+
|
|
99
|
+
if @force
|
|
100
|
+
write_file(dest, content, mode:)
|
|
101
|
+
return say("force", dest.to_s)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
say("conflict", dest.to_s)
|
|
105
|
+
@conflicts << dest.to_s
|
|
106
|
+
return :conflict
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
write_file(dest, content, mode:)
|
|
110
|
+
say("create", dest.to_s)
|
|
111
|
+
:create
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def write_file(dest, content, mode:)
|
|
115
|
+
FileUtils.mkdir_p(dest.dirname)
|
|
116
|
+
dest.write(content)
|
|
117
|
+
File.chmod(mode, dest.to_s) if mode
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def ensure_config_yml
|
|
121
|
+
path = @root.join("logica/config.yml")
|
|
122
|
+
|
|
123
|
+
if !path.exist? || @force
|
|
124
|
+
return ensure_template(path, "config.yml", mode: nil)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
begin
|
|
128
|
+
data = YAML.safe_load(path.read, permitted_classes: [], aliases: false) || {}
|
|
129
|
+
data = data.to_h
|
|
130
|
+
rescue StandardError => e
|
|
131
|
+
say("conflict", path.to_s)
|
|
132
|
+
@stderr.puts "Could not parse #{path} (#{e.class}: #{e.message}). Please merge manually or re-run with FORCE=1."
|
|
133
|
+
@conflicts << path.to_s
|
|
134
|
+
return :conflict
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
changed = false
|
|
138
|
+
unless data["engine"]
|
|
139
|
+
data["engine"] = DEFAULT_ENGINE
|
|
140
|
+
changed = true
|
|
141
|
+
end
|
|
142
|
+
unless data["output_dir"]
|
|
143
|
+
data["output_dir"] = DEFAULT_OUTPUT_DIR
|
|
144
|
+
changed = true
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
queries = data["queries"]
|
|
148
|
+
queries = {} unless queries.is_a?(Hash)
|
|
149
|
+
unless queries.key?(SAMPLE_QUERY_NAME)
|
|
150
|
+
queries[SAMPLE_QUERY_NAME] = { "program" => SAMPLE_PROGRAM_REL, "predicate" => SAMPLE_PREDICATE }
|
|
151
|
+
data["queries"] = queries
|
|
152
|
+
changed = true
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
return say("identical", path.to_s) unless changed
|
|
156
|
+
|
|
157
|
+
yaml = YAML.dump(data)
|
|
158
|
+
yaml = yaml.sub(/\A---\s*\n/, "")
|
|
159
|
+
yaml << "\n" unless yaml.end_with?("\n")
|
|
160
|
+
write_file(path, yaml, mode: nil)
|
|
161
|
+
say("update", path.to_s)
|
|
162
|
+
:update
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def ensure_requirements
|
|
166
|
+
path = @root.join("logica/requirements.txt")
|
|
167
|
+
desired = render_template("requirements.txt")
|
|
168
|
+
|
|
169
|
+
if path.exist? && !@force
|
|
170
|
+
if path.read.include?("logica==")
|
|
171
|
+
return say("identical", path.to_s)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
say("conflict", path.to_s)
|
|
175
|
+
@stderr.puts "requirements.txt exists but does not pin logica. Add:\n #{desired.strip}\nOr re-run with FORCE=1."
|
|
176
|
+
@conflicts << path.to_s
|
|
177
|
+
return :conflict
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
ensure_file(path, desired, mode: nil)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def ensure_gitignore_block
|
|
184
|
+
path = @root.join(".gitignore")
|
|
185
|
+
existed = path.exist?
|
|
186
|
+
existing = existed ? path.read : +""
|
|
187
|
+
|
|
188
|
+
block = render_template("gitignore_block")
|
|
189
|
+
return say("identical", path.to_s) if existing.include?("/logica/compiled/*")
|
|
190
|
+
|
|
191
|
+
updated = existing.dup
|
|
192
|
+
updated << "\n" unless updated.end_with?("\n") || updated.empty?
|
|
193
|
+
updated << block
|
|
194
|
+
|
|
195
|
+
write_file(path, updated, mode: nil)
|
|
196
|
+
say(existed ? "update" : "create", path.to_s)
|
|
197
|
+
existed ? :update : :create
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def say(status, message)
|
|
201
|
+
@stdout.puts format("%-10s %s", status, message)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module LogicaCompiler
|
|
8
|
+
class Manifest
|
|
9
|
+
VERSION = 1
|
|
10
|
+
|
|
11
|
+
def self.build(engine:, logica_version:, queries:, compiled_at: Time.now.utc)
|
|
12
|
+
{
|
|
13
|
+
"version" => VERSION,
|
|
14
|
+
"engine" => engine.to_s,
|
|
15
|
+
"logica" => { "pypi" => "logica", "version" => logica_version.to_s },
|
|
16
|
+
"compiled_at" => compiled_at.iso8601,
|
|
17
|
+
"queries" => queries.transform_keys(&:to_s),
|
|
18
|
+
}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.load(path)
|
|
22
|
+
JSON.parse(File.read(path.to_s))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.write!(path, manifest)
|
|
26
|
+
path = path.to_s
|
|
27
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
28
|
+
File.write(path, "#{JSON.pretty_generate(manifest)}\n")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module LogicaCompiler
|
|
6
|
+
class Registry
|
|
7
|
+
class MissingManifestError < StandardError; end
|
|
8
|
+
class InvalidManifestError < StandardError; end
|
|
9
|
+
class MissingQueryError < KeyError; end
|
|
10
|
+
|
|
11
|
+
def self.unavailable(message:)
|
|
12
|
+
Unavailable.new(message: message.to_s)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.load(manifest_path:, output_dir:, strict: false)
|
|
16
|
+
new(manifest_path:, output_dir:).tap { _1.load!(strict:) }
|
|
17
|
+
rescue Errno::ENOENT, MissingManifestError, InvalidManifestError => e
|
|
18
|
+
raise if strict
|
|
19
|
+
|
|
20
|
+
return unavailable(message: "#{e.message}. Run `bin/logica compile` again.") if e.is_a?(InvalidManifestError)
|
|
21
|
+
|
|
22
|
+
MissingManifest.new(manifest_path:)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(manifest_path:, output_dir:)
|
|
26
|
+
@manifest_path = manifest_path.to_s
|
|
27
|
+
@output_dir = output_dir.to_s
|
|
28
|
+
@entries = nil
|
|
29
|
+
@sql_cache = {}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def load!(strict: false)
|
|
33
|
+
data = JSON.parse(File.read(@manifest_path))
|
|
34
|
+
queries = data.is_a?(Hash) ? data["queries"] : nil
|
|
35
|
+
|
|
36
|
+
unless queries.is_a?(Hash)
|
|
37
|
+
raise InvalidManifestError, "Invalid Logica manifest #{@manifest_path}: missing or invalid 'queries' key"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
@entries = queries
|
|
41
|
+
self
|
|
42
|
+
rescue Errno::ENOENT
|
|
43
|
+
raise MissingManifestError, "Missing Logica manifest: #{@manifest_path}" if strict
|
|
44
|
+
|
|
45
|
+
raise
|
|
46
|
+
rescue JSON::ParserError => e
|
|
47
|
+
raise InvalidManifestError, "Invalid Logica manifest #{@manifest_path}: #{e.message}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def sql(name)
|
|
51
|
+
name = name.to_s
|
|
52
|
+
entry = entries.fetch(name) { raise MissingQueryError, "Unknown Logica query: #{name}" }
|
|
53
|
+
sql_file = entry.fetch("sql")
|
|
54
|
+
@sql_cache[name] ||= File.read(File.join(@output_dir, sql_file))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def entry(name)
|
|
58
|
+
entries.fetch(name.to_s) { raise MissingQueryError, "Unknown Logica query: #{name}" }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def entries
|
|
64
|
+
@entries || raise(MissingManifestError, "Registry not loaded")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
class MissingManifest
|
|
68
|
+
def initialize(manifest_path:)
|
|
69
|
+
@manifest_path = manifest_path.to_s
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def sql(_name)
|
|
73
|
+
raise MissingManifestError,
|
|
74
|
+
"Missing Logica manifest: #{@manifest_path}. Run `bin/logica compile` first."
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def entry(_name)
|
|
78
|
+
raise MissingManifestError,
|
|
79
|
+
"Missing Logica manifest: #{@manifest_path}. Run `bin/logica compile` first."
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
class Unavailable
|
|
84
|
+
def initialize(message:)
|
|
85
|
+
@message = message.to_s
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def sql(_name)
|
|
89
|
+
raise MissingManifestError, @message
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def entry(_name)
|
|
93
|
+
raise MissingManifestError, @message
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LogicaCompiler
|
|
4
|
+
module SqlDialect
|
|
5
|
+
class Postgres
|
|
6
|
+
def placeholder(index) = "$#{index}"
|
|
7
|
+
|
|
8
|
+
def statement_timeout_sql(connection, timeout)
|
|
9
|
+
"SET LOCAL statement_timeout = #{connection.quote(timeout)}"
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LogicaCompiler
|
|
4
|
+
class UnsafeSqlError < StandardError; end
|
|
5
|
+
|
|
6
|
+
module SqlSafety
|
|
7
|
+
PROHIBITED_KEYWORDS = %w[
|
|
8
|
+
INSERT UPDATE DELETE MERGE
|
|
9
|
+
CREATE ALTER DROP TRUNCATE
|
|
10
|
+
GRANT REVOKE
|
|
11
|
+
].freeze
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def validate!(sql)
|
|
16
|
+
stripped = strip_leading_comments(sql.to_s).lstrip
|
|
17
|
+
raise UnsafeSqlError, "SQL is empty" if stripped.empty?
|
|
18
|
+
|
|
19
|
+
raise UnsafeSqlError, "Unsafe SQL: must start with SELECT/WITH" unless stripped.match?(/\A(?:WITH|SELECT)\b/i)
|
|
20
|
+
|
|
21
|
+
sanitized = strip_strings_and_comments(stripped)
|
|
22
|
+
|
|
23
|
+
raise UnsafeSqlError, "Unsafe SQL: contains semicolon (multi-statement risk)" if sanitized.include?(";")
|
|
24
|
+
|
|
25
|
+
if sanitized.match?(/\b(?:#{PROHIBITED_KEYWORDS.join("|")})\b/i)
|
|
26
|
+
raise UnsafeSqlError, "Unsafe SQL: contains prohibited keyword"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
if sanitized.match?(/\bFOR\s+(?:UPDATE|SHARE|NO\s+KEY\s+UPDATE|KEY\s+SHARE)\b/i)
|
|
30
|
+
raise UnsafeSqlError, "Unsafe SQL: contains row locking clause"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
true
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def strip_leading_comments(sql)
|
|
37
|
+
s = sql.lstrip
|
|
38
|
+
|
|
39
|
+
loop do
|
|
40
|
+
if s.start_with?("--")
|
|
41
|
+
s = s.sub(/\A--.*(?:\n|\z)/, "").lstrip
|
|
42
|
+
next
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
if s.start_with?("/*")
|
|
46
|
+
s = s.sub(%r{\A/\*.*?\*/}m, "").lstrip
|
|
47
|
+
next
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
break
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
s
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def strip_strings_and_comments(sql)
|
|
57
|
+
s = sql.to_s
|
|
58
|
+
out = +""
|
|
59
|
+
|
|
60
|
+
i = 0
|
|
61
|
+
state = :normal
|
|
62
|
+
dollar_delim = nil
|
|
63
|
+
|
|
64
|
+
while i < s.length
|
|
65
|
+
ch = s[i]
|
|
66
|
+
|
|
67
|
+
case state
|
|
68
|
+
when :normal
|
|
69
|
+
if ch == "-" && s[i + 1] == "-"
|
|
70
|
+
state = :line_comment
|
|
71
|
+
out << " "
|
|
72
|
+
i += 2
|
|
73
|
+
elsif ch == "/" && s[i + 1] == "*"
|
|
74
|
+
state = :block_comment
|
|
75
|
+
out << " "
|
|
76
|
+
i += 2
|
|
77
|
+
elsif ch == "'"
|
|
78
|
+
state = :single_quote
|
|
79
|
+
out << " "
|
|
80
|
+
i += 1
|
|
81
|
+
elsif ch == "\""
|
|
82
|
+
state = :double_quote
|
|
83
|
+
out << " "
|
|
84
|
+
i += 1
|
|
85
|
+
elsif ch == "$"
|
|
86
|
+
delim = parse_dollar_delimiter(s, i)
|
|
87
|
+
if delim
|
|
88
|
+
dollar_delim = delim
|
|
89
|
+
state = :dollar_quote
|
|
90
|
+
out << (" " * delim.length)
|
|
91
|
+
i += delim.length
|
|
92
|
+
else
|
|
93
|
+
out << ch
|
|
94
|
+
i += 1
|
|
95
|
+
end
|
|
96
|
+
else
|
|
97
|
+
out << ch
|
|
98
|
+
i += 1
|
|
99
|
+
end
|
|
100
|
+
when :line_comment
|
|
101
|
+
if ch == "\n"
|
|
102
|
+
state = :normal
|
|
103
|
+
out << "\n"
|
|
104
|
+
else
|
|
105
|
+
out << " "
|
|
106
|
+
end
|
|
107
|
+
i += 1
|
|
108
|
+
when :block_comment
|
|
109
|
+
if ch == "*" && s[i + 1] == "/"
|
|
110
|
+
state = :normal
|
|
111
|
+
out << " "
|
|
112
|
+
i += 2
|
|
113
|
+
else
|
|
114
|
+
out << " "
|
|
115
|
+
i += 1
|
|
116
|
+
end
|
|
117
|
+
when :single_quote
|
|
118
|
+
if ch == "'"
|
|
119
|
+
if s[i + 1] == "'"
|
|
120
|
+
out << " "
|
|
121
|
+
i += 2
|
|
122
|
+
else
|
|
123
|
+
state = :normal
|
|
124
|
+
out << " "
|
|
125
|
+
i += 1
|
|
126
|
+
end
|
|
127
|
+
else
|
|
128
|
+
out << " "
|
|
129
|
+
i += 1
|
|
130
|
+
end
|
|
131
|
+
when :double_quote
|
|
132
|
+
if ch == "\""
|
|
133
|
+
if s[i + 1] == "\""
|
|
134
|
+
out << " "
|
|
135
|
+
i += 2
|
|
136
|
+
else
|
|
137
|
+
state = :normal
|
|
138
|
+
out << " "
|
|
139
|
+
i += 1
|
|
140
|
+
end
|
|
141
|
+
else
|
|
142
|
+
out << " "
|
|
143
|
+
i += 1
|
|
144
|
+
end
|
|
145
|
+
when :dollar_quote
|
|
146
|
+
if dollar_delim && s[i, dollar_delim.length] == dollar_delim
|
|
147
|
+
out << (" " * dollar_delim.length)
|
|
148
|
+
i += dollar_delim.length
|
|
149
|
+
dollar_delim = nil
|
|
150
|
+
state = :normal
|
|
151
|
+
else
|
|
152
|
+
out << " "
|
|
153
|
+
i += 1
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
out
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def parse_dollar_delimiter(s, start_index)
|
|
162
|
+
return nil unless s[start_index] == "$"
|
|
163
|
+
|
|
164
|
+
j = start_index + 1
|
|
165
|
+
while j < s.length && s[j] != "$"
|
|
166
|
+
return nil unless s[j].match?(/[A-Za-z0-9_]/)
|
|
167
|
+
|
|
168
|
+
j += 1
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
return nil unless j < s.length && s[j] == "$"
|
|
172
|
+
|
|
173
|
+
s[start_index..j]
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :logica_compiler do
|
|
4
|
+
desc "Install LogicaCompiler integration into the current Rails app"
|
|
5
|
+
task install: :environment do
|
|
6
|
+
require "pathname"
|
|
7
|
+
require "logica_compiler/installer"
|
|
8
|
+
|
|
9
|
+
root =
|
|
10
|
+
if defined?(Rails) && Rails.respond_to?(:root)
|
|
11
|
+
Pathname(Rails.root)
|
|
12
|
+
else
|
|
13
|
+
Pathname(Dir.pwd)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
force = ENV["FORCE"] == "1"
|
|
17
|
+
|
|
18
|
+
LogicaCompiler::Installer.new(root:, force:).run!
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Demo program for LogicaCompiler.
|
|
2
|
+
# Run:
|
|
3
|
+
# bin/logica install
|
|
4
|
+
# bin/logica compile <%= sample_query_name %>
|
|
5
|
+
#
|
|
6
|
+
# Then execute via Rails:
|
|
7
|
+
# Rails.application.config.x.logica.runner.exec(:<%= sample_query_name %>)
|
|
8
|
+
#
|
|
9
|
+
<%= sample_predicate %>(greeting: "Hello from LogicaCompiler!");
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
strict = Rails.env.production?
|
|
2
|
+
|
|
3
|
+
config =
|
|
4
|
+
begin
|
|
5
|
+
LogicaCompiler::Config.load!(root: Rails.root)
|
|
6
|
+
rescue Errno::ENOENT, Psych::SyntaxError => e
|
|
7
|
+
raise if strict
|
|
8
|
+
|
|
9
|
+
Rails.logger&.warn("[logica_compiler] config load failed (#{e.class}): #{e.message}")
|
|
10
|
+
LogicaCompiler::Config.new(config_path: "logica/config.yml", root: Rails.root)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
registry =
|
|
14
|
+
if !strict && !Rails.root.join("logica/config.yml").exist?
|
|
15
|
+
LogicaCompiler::Registry.unavailable(
|
|
16
|
+
message: "Missing logica/config.yml. Run `bundle exec rake logica_compiler:install` and then `bin/logica compile`."
|
|
17
|
+
)
|
|
18
|
+
else
|
|
19
|
+
LogicaCompiler::Registry.load(
|
|
20
|
+
manifest_path: config.manifest_path,
|
|
21
|
+
output_dir: config.output_dir_path,
|
|
22
|
+
strict:,
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
Rails.application.config.x.logica ||= ActiveSupport::OrderedOptions.new
|
|
27
|
+
Rails.application.config.x.logica.registry = registry
|
|
28
|
+
Rails.application.config.x.logica.runner = LogicaCompiler::ActiveRecord::Runner.new(registry:)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
logica==<%= pinned_logica_version %>
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LogicaCompiler
|
|
4
|
+
module Util
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def path_like?(value)
|
|
8
|
+
s = value.to_s
|
|
9
|
+
return false if s.empty?
|
|
10
|
+
return true if s.start_with?(".", "/", "~")
|
|
11
|
+
|
|
12
|
+
separators = [File::SEPARATOR, File::ALT_SEPARATOR].compact.uniq
|
|
13
|
+
separators.any? { |sep| s.include?(sep) }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def venv_bin_dir
|
|
17
|
+
Gem.win_platform? ? "Scripts" : "bin"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def command_candidates(cmd)
|
|
21
|
+
cmd = cmd.to_s
|
|
22
|
+
candidates = [cmd]
|
|
23
|
+
return candidates unless Gem.win_platform? && File.extname(cmd).empty?
|
|
24
|
+
|
|
25
|
+
pathext = ENV.fetch("PATHEXT", "").split(";").reject(&:empty?)
|
|
26
|
+
pathext = %w[.exe .bat .cmd] if pathext.empty?
|
|
27
|
+
pathext.map { |ext| "#{cmd}#{ext}" }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def executable_file?(path)
|
|
31
|
+
File.file?(path) && File.executable?(path)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def find_executable_in_dir(dir, cmd)
|
|
35
|
+
dir = dir.to_s
|
|
36
|
+
command_candidates(cmd).each do |candidate|
|
|
37
|
+
path = File.join(dir, candidate)
|
|
38
|
+
return path if executable_file?(path)
|
|
39
|
+
end
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def which(cmd)
|
|
44
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |dir|
|
|
45
|
+
path = find_executable_in_dir(dir, cmd)
|
|
46
|
+
return path if path
|
|
47
|
+
end
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|