Skip to content

Add query support for the static methods of System.Decimal #1533

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged

Conversation

weelink
Copy link
Contributor

@weelink weelink commented Jan 15, 2018

Fixes #831

  • Firebird
  • MySql
  • Oracle Managed 32
  • PostgreSQL
  • SQLite
  • SQL Server
  • SQL Server Compact

@weelink
Copy link
Contributor Author

weelink commented Jan 15, 2018

Since this is my first contribution and the change was too easy, could someone inspect this PR more than usual?

Copy link
Member

@fredericDelaporte fredericDelaporte left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just a quick first review. FireBird fails because Value is a keyword and must be escaped with back-tilt in the mappings, by specifying the Column property.

ReflectHelper.GetMethodDefinition(() => decimal.Round(default(decimal))),
ReflectHelper.GetMethodDefinition(() => decimal.Round(default(decimal), default(int))),
ReflectHelper.GetMethodDefinition(() => decimal.Round(default(decimal), default(MidpointRounding))),
ReflectHelper.GetMethodDefinition(() => decimal.Round(default(decimal), default(int), default(MidpointRounding))),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is MidpointRounding argument actually supported? It looks likely to me it is in fact ignored. In such case, better do as the Math generator do: do not support overloads with this argument.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably not. I'll remove this option. It worked in the tests because I was passing a Func<> and not an Expression<>.

CanHandle(e => decimal.Subtract(2m, e.Value) > 1m);
}

private void CanHandle(Func<Entity, bool> predicate)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this be Expression<Func<Entity, bool>>?

Copy link
Contributor Author

@weelink weelink Jan 16, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've updated the code so that it handles Expression<Func<Expression, bool>>

Copy link
Member

@fredericDelaporte fredericDelaporte left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some database needs to be ignored for some tests, see comments.

SQLite completly fails. Maybe due to its broken support of decimal (it does not actually support them, it stores them as float instead ; TestDialect.HasBrokenDecimalType is true for SQLite due to this), but I would have still expected other test results for it. It needs some investigation I think.

[Test]
public void CanHandleCeiling()
{
CanHandle(e => decimal.Ceiling(e.EntityValue) > 1.0m);
Copy link
Member

@fredericDelaporte fredericDelaporte Jan 17, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oracle8iDialect bug showcased here. Its ceiling function is registered under the name ceil instead of being registered as ceiling.
RegisterFunction("ceil", new StandardSQLFunction("ceil"));
Changing it would be a breaking change, so instead an additional registration would be needed, by adding the line:

RegisterFunction("ceiling", new StandardSQLFunction("ceil"));

Since this bug is a bit unrelated to #831, I am adding a PR for it (#1535).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed this as well and wanted to create a new bug for it, but you beat me to it

[Test]
public void CanHandleRemainder()
{
CanHandle(e => decimal.Remainder(e.EntityValue, 2) == 0);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some databases (SQLServerCE) do not support remainder on decimal. So a new property on TestDialect is needed, like SupportsModuloOnDecimal, true by default, and overridden to false in MsSqlCe40TestDialect.

Then remainder test should Assume.That(TestDialect.SupportsModuloOnDecimal, Is.True);

Firebird has a special semantic for modulo on non integer arguments: it rounds them beforehand. Thus the test failure. I think we should just consider it does not support modulo on decimal, so override SupportsModuloOnDecimal to false in FirebirdTestDialect too.

[Test]
public void CanHandleDivide()
{
CanHandle(e => decimal.Divide(e.EntityValue, 1.25m) < 1);
Copy link
Member

@fredericDelaporte fredericDelaporte Jan 17, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some databases (Firebird) do not support divide/multiply on decimal. So a new property on TestDialect is needed, like SupportsDecimalMultiplication, true by default, and overridden to false in FirebirdTestDialect.

Then divide and multiply tests should Assume.That(TestDialect.SupportsDecimalMultiplication, Is.True);

[Test]
public void CanHandleAdd()
{
CanHandle(e => decimal.Add(e.EntityValue, 2) > 3.0m);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Its failure on Firebird seems to me due to some Firebird limitation. It looks like the operation on the column is enough to cause Firebird to lose track of the data type.
Maybe a workaround could be to add a cast, but I think we should just ignore that Firebird limitation with something like:

Assume.That(TestDialect.SupportsNonDataBoundCondition, Is.True);

Granted, here the condition is data bound, but the addition (or subtraction) on the data causes the same trouble. And I lack an idea for a new TestDialect property name for such a case.

@weelink
Copy link
Contributor Author

weelink commented Jan 17, 2018

I need to investigate the problems with SQLite a bit more. The results of executing the queries in the test are not consistent with what I see if I run the queries myself in a SQLite client. E.g. 'ceiling' is not supported if I run the query in the client, but I get no exception in the test. And the CanHandleAdd-test, that is doing a simple where (EntityValue + x) > y, returns no result.

There might be something strange going on.

@fredericDelaporte
Copy link
Member

fredericDelaporte commented Jan 17, 2018

If some functions are not supported and have to be ignored, something like

Assume.That(Dialect.Functions, Does.ContainKey("ceiling"), Dialect + " doesn't support ceiling function.");

should take care of it. Of course this would also ignore the trouble for Oracle which only registers ceil. But since this is not the subject of this PR to fix registered functions, it is ok. (The SQL ANSI standard supports both ceil and ceiling. Many db implements both (MySql, Postgres, Firebird, ...), some implements only one (Oracle, SQL Server).)

Update: now better use AssumeFunctionSupported inherited from TestCase.

@weelink
Copy link
Contributor Author

weelink commented Jan 18, 2018

It seems that NHibernate and/or SQLite are in a disagreement about how to interpret the return type of the methods. The following does not work:

session.Query<Entity>()
    .Where(e => decimal.Add(e.EntityValue, 2) > 3.0m); // Results in an empty list, but several are expected

Adding an explicit Convert.ToDecimal does return the correct results:

session.Query<Entity>()
    .Where(e => Convert.ToDecimal(decimal.Add(e.EntityValue, 2)) > 3.0m) // Results in the expected list

This will be translated into cast(entity0_.EntityValue+@p0 as NUMERIC). This is not necessary if I execute the query manually in a client, so I don't think the problem is in SQLite.

@fredericDelaporte Do you have a possible direction for a solution? I'm not familiar with the codebase enough to figure it out on my own.

@fredericDelaporte
Copy link
Member

fredericDelaporte commented Jan 18, 2018

This is not necessary if I execute the query manually in a client, so I don't think the problem is in SQLite.

Executing the query manually is probably done without using System.Data.SQLite, so the trouble could be there too.

Another cause may be a kind of parameter type mismatch with the resulting type from addition. So this may be dodged by using .MappedAs(NHibernateUtil.Double) or some other type on 3.0m. Or maybe by changing the decimal type registration in SQLiteDialect. It is currently numeric, but you could try with real which is the actual type used by SQLite. (Well, numeric can also result into integer type being used, if the value is indeed an integer.)
(The SQLite typing system looks quite messy to me. It has only five "in-memory" types (see data types, but can store them on disk with many more data-types, for which I am not finding an official documentation.)

By the way, have you tried the query with an explicitly decimal literal? decimal.Add(e.EntityValue, 2m)

CanHandle(e => decimal.Subtract(2m, e.EntityValue) > 1m);
}

private void CanHandle(Expression<Func<Entity, bool>> predicate)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A more complete test would be to not only test filtering (what does this CanHandle, which could be renamed CanFilter) but also test selecting.

Can you add a CanSelect test ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea. Will add it shortly

}
}

private void IgnoreIfNotSupported(string function)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the merge of #1535 in master, this helper should be removed for using AssumeFunctionSupported (inherited from TestCase) instead.

@weelink
Copy link
Contributor Author

weelink commented Jan 21, 2018

I've added a CanSelect test.

It turns out that the return types are different. The return type of the mod function is Int32, but decimal.Remainder returns a decimal. The IQueryTranslator correctly returns a collection of type int, and then it crashes because it wants to add the (int) results to the decimal collection in NHibernate.Engine.Query.HQLQueryPlan line 149.

To make the test pass for SQL Server, I registered a new mod function to the dialects. But it is a temporary hack that I'll remove later when I figured out how deep the rabbit hole goes.

Adding an explicit cast to decimal solved this mismatch.

From what I understand from the documentation of SQLite, it doesn't have a type system like other databases do. It determines the type of the value by looking at the value, not the column definition. It ignores the declared type.

Changing the parameter to a decimal explicitly (i.e. 3m) doesn't help, but .MappedAs(NHibernateUtil.Double) solves the problem.

@fredericDelaporte
Copy link
Member

fredericDelaporte commented Jan 21, 2018

It turns out that the return types are different. The return type of the mod function is Int32, but decimal.Remainder returns a decimal.

So here we have a behavior mismatch to investigate. In .Net runtime, executing decimal.Remainder(5.2m, 5.1m) yields 0.1m. So using a function yielding only int to emulate this cannot yield the same result.

Should we accept such a mismatch? Well, if no database implements a decimal remainder yielding non integer results, it would be moot to not accept it. SQL Server seems to yield only integer results by example.

{
IEnumerable<decimal> inSession = session.Query<Entity>().Select(predicate).ToList();

Assert.That(inSession.OrderBy(x => x), Is.EqualTo(expected.OrderBy(x => x)).Within(0.001M));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would rather avoid the order by on result values, which may hide a trouble (granted, unlikely). Instead, the inSession query could be completed by an .OrderBy(e => e.EntityValue) before the Select, which should ensure the db results have been computed on a list having the same ordering than the in-memory one.

return treeBuilder.Multiply(expressions[0], expressions[1]);
case "remainder":
HqlMethodCall mod = treeBuilder.MethodCall("mod", expressions[0], expressions[1]);
return treeBuilder.Cast(mod, typeof(decimal));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clearly a better solution. Now is it needed to have the cast in the resulting SQL query? If no, you could use a TransparentCast instead, which is just a hint for the HQL engine, and does not go into the generated SQL query. (Well, for SQLite, it does still perform the cast in SQL too, because that db has special needs.)

hazzik
hazzik previously approved these changes Mar 3, 2018
@hazzik hazzik dismissed their stale review March 3, 2018 12:39

Actually, there is possibly incorrect generation for compare. Need to add tests which consider nullable columns

@weelink
Copy link
Contributor Author

weelink commented Mar 3, 2018

Thanks for all the help

@hazzik hazzik requested a review from fredericDelaporte March 3, 2018 13:10
@hazzik
Copy link
Member

hazzik commented Mar 3, 2018

@fredericDelaporte if my changes are OK. I'll merge the PR in a couple of hours.

hazzik
hazzik previously approved these changes Mar 3, 2018
@hazzik
Copy link
Member

hazzik commented Mar 3, 2018

I've reverted Decimal.Truncate because it needs to have more work.

@weelink
Copy link
Contributor Author

weelink commented Mar 4, 2018

I completely forgot about Decimal.Truncate. I'll try to make it work.

@weelink weelink changed the title Add query support for the static methods of System.Decimal WIP Add query support for the static methods of System.Decimal Mar 4, 2018
In Firebird the function is called 'trunc'
MySQL requires the number of decimals to be specified
@weelink weelink changed the title WIP Add query support for the static methods of System.Decimal Add query support for the static methods of System.Decimal Mar 4, 2018
@weelink
Copy link
Contributor Author

weelink commented Mar 5, 2018

The SQL Server definition can be solved by using the VarArgsSQLFunction. Not very happy with this solution, but I'm not seeing a different possibility.
We could consider adding something that can handle these situations better. But that can be done in a separate PR.

@hazzik
Copy link
Member

hazzik commented Mar 5, 2018

The SQL Server definition can be solved by using the VarArgsSQLFunction. Not very happy with this solution, but I'm not seeing a different possibility.

SQLFunctionTempate works better in that situation.

@hazzik hazzik merged commit 56e542e into nhibernate:master Mar 5, 2018
@hazzik hazzik added the r: Fixed label Mar 5, 2018
@weelink weelink deleted the feature/GH0831_AddQuerySupportForDecimal branch March 5, 2018 17:02
@@ -343,6 +343,8 @@ protected virtual void RegisterMiscellaneousFunctions()
RegisterFunction("transactsql", new StandardSQLFunction("transactsql", NHibernateUtil.String));
RegisterFunction("varexists", new StandardSQLFunction("varexists", NHibernateUtil.Int32));
RegisterFunction("watcomsql", new StandardSQLFunction("watcomsql", NHibernateUtil.String));
RegisterFunction("truncnum", new StandardSafeSQLFunction("truncnum", 2));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not think that we should register specific function names for being available in HQL when using their specific dialect, since HQL should be database agnostic. RegisterFunction("truncnum" should not have been added.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think opposite. Moreover, this is how it always was: dialect specific functions + aliases to the more common name.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Always" widely depends on dialect. Some do register specific names for some of their functions in addition to the generic one, others do not. (By example SQL-Server registers only generic names for getdate, len, datepart, charindex, same for Postgres and now, ...)
It looks to me as something quite accidental.

This practice of registering specific names does not promote portability of HQL, while an ORM is usually meant to be database agnostic. That is why I think this is a bad thing, causing me to prefer not adding new specific names when we have a generic one. (Removing them after a release would of course be a breaking change for those using them.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants