Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# AI Agent Guidelines for Granite

Welcome, AI Agents! When contributing to the Granite project, please strictly adhere to the following guidelines to ensure your code integrates seamlessly and maintains the high standards of the Amber framework.

## 1. Language Constraints
- **This is Crystal, not Ruby.** While Crystal's syntax is inspired by Ruby, it is a distinctly different, statically typed language. Do not use Ruby-isms, `method_missing` magic, or dynamic meta-programming hacks.
- Crystal is statically typed. Respect the type system and define types explicitly where it improves readability and compilation safety.

## 2. Safety and Types
- **Avoid unsafe assertions:** Never use `.not_nil!` assertions unless absolutely mathematically certain, and even then, consider alternatives. Use proper type narrowing instead (e.g., `if let`, `.try`, `.as?`, or assigning to a variable within an `if` condition like `if var = nullable_var`).

## 3. Serialization
- **Use standard serialization:** Do not use the deprecated `yaml_mapping` or `json_mapping` macros. Always use `YAML::Serializable` and `JSON::Serializable` to define serialized models and classes.

## 4. Modern Features
- **Crystal 1.20+:** Prefer modern Crystal 1.20 features. In particular, leverage features like `M:N` scheduling safe concurrency patterns when writing parallel or concurrent code.

## 5. Development Workflow
- **Formatting and Linting:** Always run `ameba` to verify linting and formatting before committing any changes. Your code must adhere to the project's formatting standards.
- **Testing:** Always run `crystal spec` to ensure all tests pass. If you are adding a new feature or fixing a bug, include corresponding specs and guarantee no regressions are introduced.

Thank you for your contributions!
1 change: 1 addition & 0 deletions spec/granite/connection_management_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ describe "Granite::Base track time since last write" do
ReplicatedChat.connection_switch_wait_period = 250
ReplicatedChat.new(content: "hello world!").save!
sleep 500.milliseconds
Fiber.current.granite_adapters.try(&.clear)
current_url = ReplicatedChat.adapter.url
reader_connection = Granite::Connections["#{ENV["CURRENT_ADAPTER"]}_with_replica"]
raise "Reader connection cannot be nil" if reader_connection.nil?
Expand Down
10 changes: 4 additions & 6 deletions spec/granite/validation_helpers/inequality_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,8 @@ describe Granite::ValidationHelpers do

less_than_test_nil = Validators::LessThanTest.new

expect_raises(Exception, "Nil assertion failed") do
less_than_test_nil.save
end
less_than_test_nil.save
less_than_test_nil.errors.size.should eq 4
end
end

Expand All @@ -47,9 +46,8 @@ describe Granite::ValidationHelpers do

greater_than_test = Validators::GreaterThanTest.new

expect_raises(Exception, "Nil assertion failed") do
greater_than_test.save
end
greater_than_test.save
greater_than_test.errors.size.should eq 4
end
end
end
16 changes: 10 additions & 6 deletions src/granite/columns.cr
Original file line number Diff line number Diff line change
Expand Up @@ -88,17 +88,21 @@ module Granite::Columns
@{{decl.var}}
end

def {{decl.var.id}}! : {{not_nilable_type}}
raise NilAssertionError.new {{@type.name.stringify}} + "#" + {{decl.var.stringify}} + " cannot be nil" if @{{decl.var}}.nil?
@{{decl.var}}.not_nil!
def {{decl.var.id}}! : {{not_nilable_type}}
val = @{{decl.var}}
raise NilAssertionError.new {{@type.name.stringify}} + "#" + {{decl.var.stringify}} + " cannot be nil" if val.nil?
val
end

{% else %}
def {{decl.var.id}}=(@{{decl.var.id}} : {{type.id}}); end

def {{decl.var.id}} : {{type.id}}
raise NilAssertionError.new {{@type.name.stringify}} + "#" + {{decl.var.stringify}} + " cannot be nil" if @{{decl.var}}.nil?
@{{decl.var}}.not_nil!
def {{decl.var.id}} : {{type.id}}
val = @{{decl.var}}
raise NilAssertionError.new {{@type.name.stringify}} + "#" + {{decl.var.stringify}} + " cannot be nil" if val.nil?
val
end

{% end %}
end

Expand Down
57 changes: 41 additions & 16 deletions src/granite/connection_management.cr
Original file line number Diff line number Diff line change
@@ -1,31 +1,37 @@
require "atomic"

class Fiber
property granite_adapters : Hash(String, Granite::Adapter::Base)?
end

module Granite::ConnectionManagement
macro included
# Default value for the time a model waits before using a reader
# database connection for read operations
# all models use this value. Change it
# to change it in all Granite::Base models.
class_property connection_switch_wait_period : Int32 = Granite::Connections.connection_switch_wait_period
@@last_write_time = Time.monotonic
@@last_write_time = Atomic(Int64).new(Time.utc.to_unix_ms)

class_property current_adapter : Granite::Adapter::Base?
# class_property current_adapter : Granite::Adapter::Base?
class_property reader_adapter : Granite::Adapter::Base?
class_property writer_adapter : Granite::Adapter::Base?

def self.last_write_time
@@last_write_time
Time.unix_ms(@@last_write_time.get)
end

# This is done this way because callbacks don't work on class mthods
def self.update_last_write_time
@@last_write_time = Time.monotonic
@@last_write_time.set(Time.utc.to_unix_ms)
end

def update_last_write_time
self.class.update_last_write_time
end

def self.time_since_last_write
Time.monotonic - @@last_write_time
Time.utc - last_write_time
end

def time_since_last_write
Expand All @@ -34,7 +40,10 @@ module Granite::ConnectionManagement

def self.switch_to_reader_adapter
if time_since_last_write > @@connection_switch_wait_period.milliseconds
@@current_adapter = @@reader_adapter
fiber_adapters = Fiber.current.granite_adapters ||= {} of String => Granite::Adapter::Base
if reader = @@reader_adapter
fiber_adapters[self.name] = reader
end
end
end

Expand All @@ -43,7 +52,10 @@ module Granite::ConnectionManagement
end

def self.switch_to_writer_adapter
@@current_adapter = @@writer_adapter
fiber_adapters = Fiber.current.granite_adapters ||= {} of String => Granite::Adapter::Base
if writer = @@writer_adapter
fiber_adapters[self.name] = writer
end
end

def switch_to_writer_adapter
Expand All @@ -53,23 +65,36 @@ module Granite::ConnectionManagement
def self.schedule_adapter_switch
return if @@writer_adapter == @@reader_adapter

spawn do
sleep @@connection_switch_wait_period.milliseconds
switch_to_reader_adapter
end

Fiber.yield
# In M:N multithreading, spawning a fiber to mutate global state or Fiber local state
# is no longer safe or deterministic. We rely on the dynamic check in `adapter` method
# and the Fiber-local scope.
end

def schedule_adapter_switch
self.class.schedule_adapter_switch
end

def self.adapter
fiber_adapters = Fiber.current.granite_adapters

if fiber_adapters && (adapter = fiber_adapters[self.name]?)
return adapter
end

if time_since_last_write > @@connection_switch_wait_period.milliseconds
if reader = @@reader_adapter
return reader
end
else
if writer = @@writer_adapter
return writer
end
end

begin
@@current_adapter.not_nil!
rescue NilAssertionError
Granite::Connections.registered_connections.first?.not_nil![:writer]
rescue NilAssertionError
raise "No registered connections found"
end
end
end
Expand All @@ -86,6 +111,6 @@ module Granite::ConnectionManagement

self.writer_adapter = Granite::Connections[{{name}}].not_nil![:writer]
self.reader_adapter = Granite::Connections[{{name}}].not_nil![:reader]
self.current_adapter = @@writer_adapter
# self.current_adapter = @@writer_adapter
end
end
9 changes: 9 additions & 0 deletions src/granite/query/assemblers/base.cr
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,14 @@ module Granite::Query::Assembler
[Model.fields].flatten.join ", "
end

@[TargetFeature("+avx2")]
def build_sql(&)
clauses = [] of String?
yield clauses
clauses.compact!.join " "
end

@[TargetFeature("+avx2")]
def where
return @where if @where

Expand Down Expand Up @@ -87,6 +89,7 @@ module Granite::Query::Assembler
@where = clauses.join(" ")
end

@[TargetFeature("+avx2")]
def order(use_default_order = true)
return @order if @order

Expand All @@ -113,6 +116,7 @@ module Granite::Query::Assembler
@order = "ORDER BY #{order_clauses.join ", "}"
end

@[TargetFeature("+avx2")]
def group_by
return @group_by if @group_by
group_fields = @query.group_fields
Expand Down Expand Up @@ -143,6 +147,7 @@ module Granite::Query::Assembler
[{field: Model.primary_name, direction: "ASC"}]
end

@[TargetFeature("+avx2")]
def count : (Executor::MultiValue(Model, Int64) | Executor::Value(Model, Int64))
sql = build_sql do |s|
s << "SELECT COUNT(*)"
Expand All @@ -161,6 +166,7 @@ module Granite::Query::Assembler
end
end

@[TargetFeature("+avx2")]
def first(n : Int32 = 1) : Executor::List(Model)
sql = build_sql do |s|
s << "SELECT #{field_list}"
Expand All @@ -175,6 +181,7 @@ module Granite::Query::Assembler
Executor::List(Model).new sql, numbered_parameters
end

@[TargetFeature("+avx2")]
def delete
sql = build_sql do |s|
s << "DELETE FROM #{table_name}"
Expand All @@ -187,6 +194,7 @@ module Granite::Query::Assembler
end
end

@[TargetFeature("+avx2")]
def select
sql = build_sql do |s|
s << "SELECT #{field_list}"
Expand All @@ -201,6 +209,7 @@ module Granite::Query::Assembler
Executor::List(Model).new sql, numbered_parameters
end

@[TargetFeature("+avx2")]
def exists? : Executor::Value(Model, Bool)
sql = build_sql do |s|
s << "SELECT EXISTS(SELECT 1 "
Expand Down
1 change: 1 addition & 0 deletions src/granite/query/assemblers/mysql.cr
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ module Granite::Query::Assembler
class Mysql(Model) < Base(Model)
@placeholder = "?"

@[TargetFeature("+avx2")]
def add_parameter(value : Granite::Columns::Type) : String
@numbered_parameters << value
"?"
Expand Down
1 change: 1 addition & 0 deletions src/granite/query/assemblers/pg.cr
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ module Granite::Query::Assembler
class Pg(Model) < Base(Model)
@placeholder = "$"

@[TargetFeature("+avx2")]
def add_parameter(value : Granite::Columns::Type) : String
@numbered_parameters << value
"$#{@numbered_parameters.size}"
Expand Down
1 change: 1 addition & 0 deletions src/granite/query/assemblers/sqlite.cr
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ module Granite::Query::Assembler
class Sqlite(Model) < Base(Model)
@placeholder = "?"

@[TargetFeature("+avx2")]
def add_parameter(value : Granite::Columns::Type) : String
@numbered_parameters << value
"?"
Expand Down
10 changes: 10 additions & 0 deletions src/granite/query/builder.cr
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ class Granite::Query::Builder(Model)
where(matches)
end

@[TargetFeature("+avx2")]
def where(matches)
matches.each do |field, value|
if value.is_a?(Array)
Expand All @@ -77,12 +78,14 @@ class Granite::Query::Builder(Model)
and(stmt: stmt, value: value)
end

@[TargetFeature("+avx2")]
def and(field : (Symbol | String), operator : Symbol, value : Granite::Columns::Type)
@where_fields << {join: :and, field: field.to_s, operator: operator, value: value}

self
end

@[TargetFeature("+avx2")]
def and(stmt : String, value : Granite::Columns::Type = nil)
@where_fields << {join: :and, stmt: stmt, value: value}

Expand All @@ -93,6 +96,7 @@ class Granite::Query::Builder(Model)
and(matches)
end

@[TargetFeature("+avx2")]
def and(matches)
matches.each do |field, value|
if value.is_a?(Array)
Expand All @@ -110,6 +114,7 @@ class Granite::Query::Builder(Model)
or(matches)
end

@[TargetFeature("+avx2")]
def or(matches)
matches.each do |field, value|
if value.is_a?(Array)
Expand All @@ -123,18 +128,21 @@ class Granite::Query::Builder(Model)
self
end

@[TargetFeature("+avx2")]
def or(field : (Symbol | String), operator : Symbol, value : Granite::Columns::Type)
@where_fields << {join: :or, field: field.to_s, operator: operator, value: value}

self
end

@[TargetFeature("+avx2")]
def or(stmt : String, value : Granite::Columns::Type = nil)
@where_fields << {join: :or, stmt: stmt, value: value}

self
end

@[TargetFeature("+avx2")]
def order(field : Symbol)
@order_fields << {field: field.to_s, direction: Sort::Ascending}

Expand All @@ -153,6 +161,7 @@ class Granite::Query::Builder(Model)
order(dsl)
end

@[TargetFeature("+avx2")]
def order(dsl)
dsl.each do |field, dsl_direction|
direction = Sort::Ascending
Expand Down Expand Up @@ -185,6 +194,7 @@ class Granite::Query::Builder(Model)
group_by(dsl)
end

@[TargetFeature("+avx2")]
def group_by(dsl)
dsl.each do |field|
@group_fields << {field: field.to_s}
Expand Down
4 changes: 2 additions & 2 deletions src/granite/validation_helpers/inequality.cr
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
module Granite::ValidationHelpers
macro validate_greater_than(field, amount, or_equal_to = false)
validate {{field}}, "#{{{field}}} must be greater than#{{{or_equal_to}} ? " or equal to" : ""} #{{{amount}}}", Proc(self, Bool).new { |model| (model.{{field.id}}.not_nil! {% if or_equal_to %} >= {% else %} > {% end %} {{amount.id}}) }
validate {{field}}, "#{{{field}}} must be greater than#{{{or_equal_to}} ? " or equal to" : ""} #{{{amount}}}", Proc(self, Bool).new { |model| ((val = model.{{field.id}}) ? (val {% if or_equal_to %} >= {% else %} > {% end %} {{amount.id}}) : false) }
end

macro validate_less_than(field, amount, or_equal_to = false)
validate {{field}}, "#{{{field}}} must be less than#{{{or_equal_to}} ? " or equal to" : ""} #{{{amount}}}", Proc(self, Bool).new { |model| (model.{{field.id}}.not_nil! {% if or_equal_to %} <= {% else %} < {% end %} {{amount.id}}) }
validate {{field}}, "#{{{field}}} must be less than#{{{or_equal_to}} ? " or equal to" : ""} #{{{amount}}}", Proc(self, Bool).new { |model| ((val = model.{{field.id}}) ? (val {% if or_equal_to %} <= {% else %} < {% end %} {{amount.id}}) : false) }
end
end
4 changes: 2 additions & 2 deletions src/granite/validation_helpers/length.cr
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
module Granite::ValidationHelpers
macro validate_min_length(field, length)
validate {{field}}, "#{{{field}}} is too short. It must be at least #{{{length}}}", Proc(self, Bool).new { |model| (model.{{field.id}}.not_nil!.size >= {{length.id}}) }
validate {{field}}, "#{{{field}}} is too short. It must be at least #{{{length}}}", Proc(self, Bool).new { |model| ((val = model.{{field.id}}) ? val.size >= {{length.id}} : false) }
end

macro validate_max_length(field, length)
validate {{field}}, "#{{{field}}} is too long. It must be at most #{{{length}}}", Proc(self, Bool).new { |model| (model.{{field.id}}.not_nil!.size <= {{length.id}}) }
validate {{field}}, "#{{{field}}} is too long. It must be at most #{{{length}}}", Proc(self, Bool).new { |model| ((val = model.{{field.id}}) ? val.size <= {{length.id}} : true) }
end
end